@eamodio/supertalk-signals
v0.0.6
Published
TC39 Signals integration for Supertalk
Maintainers
Readme
@supertalk/signals
[!WARNING] This is a pre-release package under active development. APIs may change without notice between versions.
TC39 Signals integration for Supertalk. Synchronize reactive state across workers with automatic batched updates.
Features
- Reactive across boundaries: Signals on the sender side become
RemoteSignals on the receiver that trigger local effects - Synchronous reads: Initial values are available immediately via
get() - Batched updates: Multiple signal changes are coalesced into a single message
- Lazy watching: Source signals are only watched when the receiver observes
reactively, respecting
[Signal.subtle.watched]callbacks - Works with
Signal.StateandSignal.Computed - Modern JavaScript: Published as ES2024, targeting current browsers and Node.js 20+
Installation
npm install @supertalk/signals signal-polyfillNote: This package requires
signal-polyfillfor the TC39 Signals API.
Quick Start
worker.ts (exposed side):
import {expose} from '@supertalk/core';
import {Signal} from 'signal-polyfill';
import {SignalHandler} from '@supertalk/signals';
const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);
const service = {
getCount: () => count,
getDoubled: () => doubled,
increment: () => count.set(count.get() + 1),
};
expose(service, self, {handlers: [new SignalHandler()]});main.ts (wrapped side):
import {wrap} from '@supertalk/core';
import {Signal} from 'signal-polyfill';
import {SignalHandler} from '@supertalk/signals';
const worker = new Worker('./worker.ts');
const remote = await wrap<typeof service>(worker, {
handlers: [new SignalHandler()],
});
// Get the remote signal (initial value available synchronously)
const count = await remote.getCount();
console.log(count.get()); // 0
// Set up reactive observation with a Watcher
const watcher = new Signal.subtle.Watcher(() => {
// Handle updates
});
const quadrupled = new Signal.Computed(() => count.get() * 4);
watcher.watch(quadrupled);
quadrupled.get(); // Establish the subscription chain
// Mutate on worker side
await remote.increment();
// After microtask, updates propagate (because watcher is watching quadrupled)
console.log(count.get()); // 1
console.log(quadrupled.get()); // 4API
SignalHandler
Coordinates signal synchronization across a connection. Create one per endpoint.
const signalHandler = new SignalHandler(options);
// Options:
interface SignalHandlerOptions {
/**
* Whether to automatically watch signals when sent (default: false).
*
* - false: Lazy watching. Signals are only watched when the receiver
* observes them reactively. Respects [Signal.subtle.watched] callbacks.
*
* - true: Eager watching. Signals are watched immediately when sent.
* Updates always flow regardless of whether receiver is observing.
*/
autoWatch?: boolean;
}Lazy vs Eager Watching
By default (autoWatch: false), signals are watched lazily:
// Sender has a signal with a watched callback
const data = new Signal.State(initialData, {
[Signal.subtle.watched]: () => startExpensiveDataFetch(),
[Signal.subtle.unwatched]: () => stopExpensiveDataFetch(),
});
// Sending the signal does NOT trigger the watched callback
const remoteData = await remote.getData();
// Only when something observes the RemoteSignal reactively...
const computed = new Signal.Computed(() => remoteData.get());
// ...does the sender start watching (and the callback fires)Use autoWatch: true when you want updates to always flow:
const signalHandler = new SignalHandler({autoWatch: true});RemoteSignal<T>
A read-only signal that receives updates from the sender side. You don't create these directly—they're returned when you access a signal property on a remote service.
const count = await remote.getCount(); // RemoteSignal<number>
count.get(); // Read current value (reactive)
count.set(42); // Throws! RemoteSignals are read-onlyRemoteSignals integrate with the TC39 Signals reactivity system:
import {Signal} from 'signal-polyfill';
// Local computeds can depend on remote signals
const doubled = new Signal.Computed(() => count.get() * 2);
// Effects track remote signals too
const watcher = new Signal.subtle.Watcher(() => {
console.log('count changed!');
});
watcher.watch(new Signal.Computed(() => count.get()));How It Works
- When a
Signal.StateorSignal.Computedis sent across the boundary, theSignalHandlerassigns it an ID and sends the current value - The receiver creates a
RemoteSignalwith that initial value - When something observes the
RemoteSignalreactively, a watch message is sent to the sender - The sender starts monitoring the signal for changes via a
Watcher - When signals change, updates are batched via
queueMicrotaskand sent as a single message - When the receiver stops observing, an unwatch message is sent and the sender stops monitoring
With autoWatch: true, steps 3-4 happen immediately when the signal is sent.
Limitations
- One-way sync: Signals flow from sender to receiver.
RemoteSignals are read-only. - Requires handler on both sides: Both
expose()andwrap()need aSignalHandlerin their handlers array. - Lazy watching requires reactive observation: With the default
autoWatch: false, calling.get()outside a reactive context (computed, effect, watcher) won't trigger updates. UseautoWatch: trueif you need updates without reactive observation.
