Skip to content

Latest commit

 

History

History

README.md

@supertalk/core

CI npm version License: MIT

Warning

This is a pre-release package under active development. APIs may change without notice between versions.

A type-safe, unified communication library for Web Workers, Iframes, and Node.js worker threads.

Overview

Supertalk turns workers' low-level message passing into a high-level, type-safe RPC layer—so you can call methods, pass callbacks, and await promises as if they were local.

Supertalk is built to be a joy to use and deploy:

  • Type-safe: Your IDE knows exactly what's proxied vs cloned
  • Ergonomic: Callbacks, promises, and classes just work
  • Bidirectional: The same types and patterns work in both directions
  • Fast & small: ~2.4 kB brotli-compressed, zero dependencies
  • Composable & extendable: Non-global configuration, nested objects, services are just classes, composable transport handlers
  • Standard modules: Some people call them "ESM". We don't publish CJS.
  • Modern JavaScript: Published as ES2024, targeting current browsers and Node.js 20+

Installation

npm install @supertalk/core

Quick Start

worker.ts (exposed side):

import {expose} from '@supertalk/core';

const service = {
  add(a: number, b: number): number {
    return a + b;
  },
  async fetchData(url: string): Promise<string> {
    const res = await fetch(url);
    return res.text();
  },
};

expose(service, self);

main.ts (wrapped side):

import {wrap} from '@supertalk/core';

const worker = new Worker('./worker.ts');
const remote = await wrap<typeof service>(worker);

// Methods become async
const result = await remote.add(1, 2); // 3

Core Features

Automatic proxying of functions and promises

Functions and promises passed as top-level arguments or return values are always proxied:

// Exposed side
const service = {
  forEach(items: number[], callback: (item: number) => void) {
    for (const item of items) {
      callback(item); // Calls back to wrapped side
    }
  },
};
// Wrapped side
await remote.forEach([1, 2, 3], (item) => {
  console.log(item); // Runs locally
});

All functions are transformed into promise-returning async functions on the wrapped side. Proxies are released from memory when they're no longer in use by the wrapped side.

Object proxying with proxy()

Objects are cloned by default. This works well for immutable data objects, but not for all cases. The proxy() function marks an object as needing to be proxied instead of cloned.

Use proxy() when returning:

  1. Mutable objects — The remote side should see updates
  2. Large graphs — Avoid cloning expensive data structures
  3. Class instances with methods — Preserve the prototype API

worker.ts:

import {expose, proxy} from '@supertalk/core';

export class Widget {
  count = 42;
  sayHello() {
    return 'hello';
  }
}

expose(
  {
    createWidget() {
      return proxy(new Widget()); // Explicitly proxied
    },
  },
  self,
);

main.ts:

const service = await wrap(worker);
const widget = await service.createWidget();

// The proxy provides async access to properties and methods
const count = await widget.count;
const hello = await widget.sayHello();

Opaque Handles with handle()

When you need to pass a reference without exposing any remote interface, use handle(). Handles are opaque tokens — they can be passed around and back, but provide no way to access properties or methods remotely.

import {expose, handle, getHandleValue} from '@supertalk/core';

class Session {
  constructor(public id: string) {}
}

const service = {
  createSession(id: string) {
    return handle(new Session(id)); // Opaque handle
  },
  getSessionId(session: Handle<Session>) {
    // Extract the value on the owning side
    return getHandleValue(session).id;
  },
};
// Client side
const session = await remote.createSession('abc'); // Handle<Session>
// session is opaque — no property access
const id = await remote.getSessionId(session); // Pass it back

Extracting Values from Proxies and Handles

Both proxy() and handle() create wrappers that work the same on both sides. On the side that created the proxy/handle, you can extract the underlying value:

import {proxy, getProxyValue, handle, getHandleValue} from '@supertalk/core';

const service = {
  // Return a proxied widget
  createWidget() {
    return proxy(new Widget());
  },

  // Accept a proxy and extract its value
  updateWidget(widget: AsyncProxy<Widget>) {
    const w = getProxyValue(widget); // Only works on owning side
    w.refresh();
  },

  // Same pattern works for handles
  processSession(session: Handle<Session>) {
    const s = getHandleValue(session);
    return s.data;
  },
};

Nested Objects & Proxies

Supertalk supports two modes for handling nested values:

  • Shallow mode (default): Maximum performance, only top-level function arguments and return values are proxied. Nested functions/promises fail with DataCloneError.
  • Nested mode (nestedProxies: true): Full payload traversal. Functions and promises anywhere in the graph are auto-proxied.
// On exposed side
expose(service, self, {nestedProxies: true});

const service = {
  getData() {
    return {
      process: (x) => x * 2, // auto-proxied (function)
      widget: proxy(new Widget()), // explicitly proxied (class)
    };
  },
};

// On wrapped side
const remote = await wrap<typeof service>(worker, {nestedProxies: true});
const data = await remote.getData();
await data.process(5); // 10

Transferables with transfer()

Use transfer() to mark values like ArrayBuffer, MessagePort, or streams to be transferred rather than cloned.

import {transfer} from '@supertalk/core';

const service = {
  getBuffer(): ArrayBuffer {
    const buf = new ArrayBuffer(1024);
    return transfer(buf); // Zero-copy transfer
  },
};

Value Handling Reference

When values cross the communication boundary:

Value Type Shallow Mode Nested Mode
Primitives ✅ Cloned ✅ Cloned
Plain objects {...} ✅ Cloned ✅ Cloned
Arrays ✅ Cloned ✅ Cloned
Functions (top-level) 🛜 Proxied 🛜 Proxied
Functions (nested) ❌ Error 🛜 Proxied
Promises (top-level return) 🛜 Proxied 🛜 Proxied
Promises (nested) ❌ Error 🛜 Proxied
proxy() wrapped values (top-level) 🛜 Proxied 🛜 Proxied
proxy() wrapped values (nested) ❌ Error 🛜 Proxied
handle() wrapped values (top-level) 🔒 Handle 🔒 Handle
handle() wrapped values (nested) ❌ Error 🔒 Handle
transfer() wrapped values (top-level) 📦 Transferred 📦 Transferred
transfer() wrapped values (nested) ❌ Error 📦 Transferred
Class instances & objects with custom prototypes ⚠️ Cloned* ⚠️ Cloned*
Other structured cloneable objects ✅ Cloned ✅ Cloned

* Class instances are cloned via structured clone (losing methods) unless wrapped in proxy().

API Reference

Core Functions

expose(target, endpoint, options?)

Exposes an object or function to the other side.

  • target: The service object or function to expose.
  • endpoint: The Worker, MessagePort, Window, or compatible interface.
  • options: Connection options (see below).

wrap<T>(endpoint, options?)

Connects to an exposed service and returns a proxy.

  • endpoint: The Worker, MessagePort, Window, or compatible interface.
  • options: Connection options (see below).
  • Returns: Promise<Remote<T>>

proxy(value)

Marks an object to be proxied rather than cloned. Returns an AsyncProxy<T> that provides async access on both sides. Use this for:

  • Class instances with methods
  • Mutable objects that should be shared
  • Large objects to avoid cloning costs

handle(value)

Marks an object as an opaque handle. Returns a Handle<T> that can be passed around but provides no remote interface. Use this for:

  • Session tokens or identifiers
  • References to expensive objects
  • Graph nodes where you don't want to expose internals

getProxyValue(proxy) / getHandleValue(handle)

Extracts the underlying value from an AsyncProxy or Handle. Only works on the side that created the proxy/handle — throws TypeError on the remote side.

transfer(value, transferables?)

Marks a value to be transferred (zero-copy) rather than cloned.

  • value: The value to send (e.g. ArrayBuffer, MessagePort).
  • transferables: Optional array of transferables. If omitted, value is assumed to be the transferable.

Options

interface Options {
  /** Enable nested proxy handling (default: false) */
  nestedProxies?: boolean;
  /**
   * Enable debug mode for better error messages.
   * Throws NonCloneableError with the exact path for nested functions,
   * promises, proxy() markers, and transfer() markers that would fail
   * without nestedProxies: true.
   */
  debug?: boolean;
  /** Custom handlers for serializing/deserializing specific types */
  handlers?: Array<Handler>;
}

Handlers

Handlers provide pluggable serialization for custom types or streams.

Stream Handler Example:

import {streamHandler} from '@supertalk/core/handlers/streams.js';

expose(service, self, {handlers: [streamHandler]});
const remote = await wrap<typeof service>(worker, {handlers: [streamHandler]});

Custom Handlers:

Handlers let you control how specific types are serialized. Each handler implements:

  • wireType — Unique identifier (e.g., 'app:my-type')
  • canHandle(value) — Returns true if this handler should process the value
  • toWire(value, ctx) — Serialize the value, using ctx.toWire() for nested values
  • fromWire(wire, ctx) — Deserialize, using ctx.fromWire() for nested values
import {WIRE_TYPE, type Handler} from '@supertalk/core';

// Handler that clones Maps by converting to/from arrays
const mapHandler: Handler<Map<unknown, unknown>, {entries: unknown[]}> = {
  wireType: 'app:map',

  canHandle: (v): v is Map<unknown, unknown> => v instanceof Map,

  toWire(map, ctx) {
    return {
      [WIRE_TYPE]: 'app:map',
      entries: [...map.entries()].map(([k, v]) => [
        ctx.toWire(k),
        ctx.toWire(v),
      ]),
    };
  },

  fromWire(wire, ctx) {
    return new Map(
      wire.entries.map(([k, v]) => [ctx.fromWire(k), ctx.fromWire(v)]),
    );
  },
};

Subscription Handlers:

For handlers that need to send updates outside of RPC calls (like signals or observables), the handler lifecycle provides messaging support:

  • connect(ctx) — Called when attached; provides ctx.sendMessage() for sending updates
  • onMessage(payload) — Called when a message arrives for this handler's wireType
  • disconnect() — Called when the connection closes; clean up resources

See @supertalk/signals for a complete example.

TypeScript Types

Remote<T>

The primary type for service proxies returned by wrap(). Transforms all methods to async and properties to Promise<T>.

const remote = await wrap<MyService>(worker);
// remote has type Remote<MyService>

AsyncProxy<T>

The unified proxy type returned by proxy(). Works the same on both sides of a connection — the remote side gets async access to properties and methods, while the owning side can extract the underlying value with getProxyValue().

// Service returns an AsyncProxy
createWidget(): AsyncProxy<Widget> {
  return proxy(new Widget());
}

// Client receives the same type
const widget = await remote.createWidget(); // AsyncProxy<Widget>
await widget.name;       // Property access is async
await widget.activate(); // Method calls are async

Handle<T>

An opaque reference type returned by handle(). Provides no remote interface — useful for session tokens, expensive objects, or graph nodes that shouldn't expose their internals.

// Service returns a Handle
createSession(): Handle<Session> {
  return handle(new Session());
}

// Client can only pass it back
const session = await remote.createSession(); // Handle<Session>
// session.foo — not allowed, handles are opaque
await remote.useSession(session); // Pass it back

Advanced Usage

Debugging

Debugging DataCloneError can be tricky. Supertalk provides a debug option that traverses your data before sending and throws a NonCloneableError with the exact path to the problematic value.

const remote = await wrap<Service>(endpoint, {debug: true});

Memory Management

Proxied objects are tracked with registries on both sides.

  • Source side: Holds strong references until released.
  • Consumer side: Holds weak references; when GC'd, notifies source to release.

Node.js Worker Threads

For Node.js worker_threads, use the nodeEndpoint adapter to convert the Node-style event API (on/off) to the browser-style API (addEventListener/removeEventListener).

main.ts:

import {wrap} from '@supertalk/core';
import {nodeEndpoint} from '@supertalk/core/node.js';
import {Worker} from 'node:worker_threads';

const worker = new Worker('./worker.js');
const remote = await wrap<MyService>(nodeEndpoint(worker));

const result = await remote.add(1, 2);
worker.terminate();

worker.ts:

import {expose} from '@supertalk/core';
import {parentPort} from 'node:worker_threads';

const service = {
  add(a: number, b: number) {
    return a + b;
  },
};

// parentPort is a MessagePort which has addEventListener/removeEventListener
expose(service, parentPort!);

Benchmarks

Supertalk vs Comlink vs Supertalk with nestedProxies: true, measured in ops/sec (higher is better). Node.js worker_threads with MessageChannel.

Benchmark Supertalk Comlink ST vs Comlink Supertalk w/
nestedProxies
nested vs
shallow
Simple String Echo 168,597 94,543 1.78x 173,684 1.03x
Multiple Arguments (4 nums) 163,503 84,546 1.93x 163,420 1x
Large Object (~10KB) 26,257 23,714 1.11x 12,886 0.49x
Large Array (10,000 items) 313 309 1.01x 163 0.52x
Callback (proxy function) 3,742 3,469 1.08x 3,607 0.96x
Multiple Callbacks (3 funcs) 1,748 1,576 1.11x 1,856 1.06x
Rapid Sequential (20x burst) 190,164 103,191 1.84x 179,378 0.94x

Notes:

  • Supertalk appears to have lower per-call and per-object overhead, which makes it faster in the multiple call and multiple argument cases, and similar in the one large object or array cases.
  • For simple calls and bursts, Supertalk is ~1.8-1.9x faster than Comlink
  • nestedProxies mode adds traversal overhead for large payloads. The performance impact ranges from negligible for small objects to 2x slower for large graphs and arrays.
  • Run the benchmarks with: npm run bench -w @supertalk/core

Background

Why Supertalk?

Workers are great for offloading work, but the raw postMessage API is difficult:

  • No built-in request/response (one-way only)
  • No error propagation
  • No functions or promises (DataCloneError)
  • Manual lifetime management
  • Manual transfer lists

Supertalk handles all of this: RPC layer, transparent proxying, automatic lifetime management, and deep traversal.

Comparison to Comlink

Supertalk is inspired by Comlink but differs in key ways:

  • Automatic proxying: Functions/promises are auto-proxied.
  • Nested support: nestedProxies mode allows proxies anywhere in the payload.
  • Debug mode: Reports exactly where non-serializable values are.
  • Symmetric: Both ends use the same Connection class.
  • No MessagePorts: Uses the worker/window directly, making it lighter.

License

MIT