Status: Exploration — This document explores API options before implementation
- Simple things simple, complex things possible
- Explicit over magic (but not verbose)
- Type safety without ceremony
- Composition over configuration
import {service, method} from '@supertalk/core';
@service()
class Calculator {
@method()
add(a: number, b: number): number {
return a + b;
}
@method()
async fetchData(url: string): Promise<Data> {
const response = await fetch(url);
return response.json();
}
}Pros:
- Familiar OOP pattern
- Clear visual distinction of exposed methods
- Room for method-level options in decorator
Cons:
- Requires decorator support
- Runtime metadata needed for some features
// shared/calculator.ts (shared package)
export interface ICalculator {
add(a: number, b: number): number;
fetchData(url: string): Promise<Data>;
}
// server/calculator.ts
import type {ICalculator} from 'shared/calculator';
export class Calculator implements ICalculator {
add(a: number, b: number): number {
return a + b;
}
// ...
}
// client/main.ts
import type {ICalculator} from 'shared/calculator';
const calc = wrap<ICalculator>(worker);Pros:
- No decorators needed
- Clean separation of interface and implementation
- Type-only imports on client
Cons:
- Requires separate shared package
- Interface must be kept in sync manually
- No runtime metadata for advanced features
// server/calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b;
}
}
// client/main.ts
import type {Calculator} from './server/calculator';
const calc = wrap<Calculator>(worker);Pros:
- Simplest setup
- No shared package needed
- Types automatically stay in sync
Cons:
- Import path to server code (even if type-only)
- Build setup must handle type-only imports correctly
- No runtime metadata
Support all three patterns:
- Decorated classes — Full features, recommended for new projects
- Shared interfaces — For projects that want explicit contracts
- Type-only imports — For quick prototyping or simple cases
import {expose} from '@supertalk/core';
@service()
class Calculator {
@method()
add(a: number, b: number): number {
return a + b;
}
}
// Expose to parent
expose(new Calculator(), self);expose(service, endpoint, options?: {
// Allow specific methods only
methods?: string[];
// Transform errors before sending
onError?: (error: Error) => unknown;
// Middleware for all calls
middleware?: Middleware[];
});import {wrap} from '@supertalk/core';
import type {Calculator} from './worker';
const worker = new Worker('./worker.js', {type: 'module'});
const calc = wrap<Calculator>(worker);
// Fully typed!
const result = await calc.add(1, 2); // Promise<number>wrap<T>(endpoint, options?: {
// Timeout for calls
timeout?: number;
// Custom serializer
serializer?: Serializer;
// AbortSignal for cleanup
signal?: AbortSignal;
});function service(options?: ServiceOptions): ClassDecorator;
interface ServiceOptions {
// Service name for debugging/logging
name?: string;
// Default options for all methods
defaults?: MethodOptions;
}function method(options?: MethodOptions): MethodDecorator;
interface MethodOptions {
// Override method name
name?: string;
// Timeout for this method
timeout?: number;
// Deep traversal options
traversal?: 'auto' | 'none' | 'shallow';
}The @clone() decorator marks a field as "send once at wrap time" instead of
creating a property proxy that requires await on every access.
Use case: Signal-valued properties that should be synchronously available:
class CounterService {
@clone() readonly count = new Signal.State(0);
increment() {
this.count.set(this.count.get() + 1);
}
}
// Without @clone(): requires await for property access
const signal = await remote.count; // Promise<RemoteSignal<number>>
// With @clone(): synchronous access
const signal = remote.count; // RemoteSignal<number> - no await!
signal.get(); // Works immediately with initial valueSemantics:
- The property's current value is transferred when
wrap()connects - The value is cloned/transferred once; the reference is immutable
- If the property is a signal, subsequent updates flow via the signal protocol
- If the property is reassigned on the service, the remote won't see it
Type impact: Fields decorated with @clone() should not be wrapped in
Promise<> in the Remote<T> type. This requires decorator metadata to be
accessible to the type system (challenging but possible with sufficiently
advanced TypeScript patterns).
The inverse of @clone() - mark a field that would normally be cloned as
"create a proxy instead":
class Service {
@proxy() readonly expensiveData = loadHugeDataset();
process(): void {
// Uses this.expensiveData locally
}
}
// expensiveData is proxied, not cloned - avoids sending huge data// Mark a method parameter as a proxy (don't clone)
@method()
doWork(@proxy callback: () => void): void;
// Mark return value as a stream
@method()
@stream()
getData(): ReadableStream<Chunk>;
// Mark a property as a signal
@signal()
accessor count: number;@service()
class Database {
@method()
getCollection(name: string): Collection {
return new Collection(name);
}
}
@service()
class Collection {
constructor(private name: string) {}
@method()
find(query: Query): Promise<Document[]> {
// ...
}
}
// Client usage
const db = wrap<Database>(worker);
const users = await db.getCollection('users'); // Returns proxy to Collection
const docs = await users.find({active: true});@service()
class EventSource {
@method()
subscribe(callback: (event: Event) => void): () => void {
// callback is automatically proxied
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
}
// Client
const source = wrap<EventSource>(worker);
const unsubscribe = await source.subscribe((event) => {
console.log('Event:', event);
});
// Later
await unsubscribe();@service()
class FileService {
@method()
readFile(path: string): ReadableStream<Uint8Array> {
// Stream is transferred, not proxied
return new ReadableStream({
/* ... */
});
}
}
// Client
const files = wrap<FileService>(worker);
const stream = await files.readFile('/data.bin');
for await (const chunk of stream) {
process(chunk);
}import {Signal} from '@supertalk/core';
@service()
class Counter {
#count = new Signal.State(0);
@signal()
get count(): Signal<number> {
return this.#count;
}
@method()
increment(): void {
this.#count.set(this.#count.get() + 1);
}
}
// Client
const counter = wrap<Counter>(worker);
const countSignal = await counter.count; // Remote signal
// React to changes
effect(() => {
console.log('Count:', countSignal.get());
});
await counter.increment(); // Triggers effectinterface Endpoint {
postMessage(message: unknown, transfer?: Transferable[]): void;
addEventListener(type: 'message', listener: MessageListener): void;
removeEventListener(type: 'message', listener: MessageListener): void;
}// Workers (built-in, no adapter needed)
expose(service, self);
wrap<T>(new Worker('...'));
// MessagePort
const {port1, port2} = new MessageChannel();
expose(service, port1);
wrap<T>(port2);
// Iframe
expose(service, iframeElement.contentWindow);
wrap<T>(window.parent);
// HTTP (future)
import {httpEndpoint} from '@supertalk/http';
const endpoint = httpEndpoint('https://api.example.com/rpc');
wrap<T>(endpoint);Errors thrown in service methods are serialized and re-thrown on the client:
@service()
class Validator {
@method()
validate(data: unknown): void {
if (!isValid(data)) {
throw new ValidationError('Invalid data', {field: 'email'});
}
}
}
// Client
try {
await validator.validate(badData);
} catch (error) {
// error is a ValidationError with message and properties
}class ValidationError extends Error {
constructor(
message: string,
public details: object,
) {
super(message);
}
// Custom serialization
static [Symbol.for('supertalk.serialize')](error: ValidationError) {
return {message: error.message, details: error.details};
}
static [Symbol.for('supertalk.deserialize')](data: object) {
return new ValidationError(data.message, data.details);
}
}-
Decorator metadata storage: Where do we store runtime metadata from decorators? WeakMap keyed by class/method?
-
Implicit vs explicit proxying: Should functions always be proxied, or require opt-in? Comlink requires
proxy(), which is error-prone. -
Property access: Should
wrap()return a proxy that allows property access, or only method calls? Property access is tricky because every access becomes async. -
Constructor calls: Should
new Remote()work? Comlink supports this but it's complex. -
Symbol methods: Should Symbol-keyed methods be exposed? Probably not by default.
-
Private methods: How do we ensure
#privatemethods aren't exposed? (They can't be, but what about_conventionprivate?)
- Finalize basic
expose()/wrap()API - Implement without decorators first (plain objects)
- Add decorator support
- Design proxy lifecycle and memory management
- Design streaming support