Custom bindings
A binding package (@atolljs/<fw>) is three thin adapters over core primitives — observe() for shared-memory fields, toTask() for task state, and ObservableValue underneath both. The whole Vue binding is ~40 lines; a port's real work is choosing the right reactive primitive and the right teardown hook.
The contract you adapt
// @atolljs/core — everything a binding wraps:
interface ObservableValue<T> {
get(): T; // latest snapshot, sync
subscribe(fn: (v: T) => void): () => void; // returns unsubscribe
}
observe(memory, key, select?, options?) // → ObservableValue<field | slice>
toTask(asyncFnOrTask) // → AsyncTask, itself an ObservableValue<TaskSnapshot>get() returns undefined until the contract is bound and the field written (or the task first runs) — keep the | undefined in your public types, it's the SSR fallback story too. subscribe fires on every write, local or remote; your adapter's job is to push each emission into a framework-tracked cell and unregister it when the surrounding scope dies.
The three adapters
The canonical minimal port is Vue's — packages/vue/src/index.ts in full:
import { onScopeDispose, ref, type Ref } from 'vue';
import { observe, toTask } from '@atolljs/core';
// 1) ObservableValue → framework reactive primitive.
export function useObservable<T>(source: ObservableValue<T>): Ref<T> {
const value = ref(source.get());
const stop = source.subscribe((v) => { value.value = v; });
onScopeDispose(stop); // ← the framework's teardown hook
return value;
}
// 2) Shared field → same primitive. observe() does the spec lookup,
// slicing, and equality; you just re-wrap.
export function useSharedValue(memory, key, select?, options?) {
return useObservable(observe(memory, key, select, options));
}
// 3) Task → snapshot cell + the trigger pair, passed through untouched.
export function useTask(source) {
const task = toTask(source); // AsyncTask | plain async fn — both
return { state: useObservable(task), run: task.run, runOnce: task.runOnce };
}Framework mapping
| Framework | Reactive primitive | Teardown hook | Idiom |
|---|---|---|---|
| React / Next.js client | useSyncExternalStore(source.subscribe, source.get) | the returned unsubscribe | use* hooks |
| Vue | ref(source.get()) + write in subscribe | onScopeDispose(stop) | use* composables returning Refs |
| Solid | createSignal(source.get()) → accessor | onCleanup(stop) | create* factories returning accessors |
| Svelte 5 | $state box in a .svelte.ts module | return stop from $effect | factories returning getter objects / rune-backed state |
| Angular | signal(source.get()) + write in subscribe | inject(DestroyRef).onDestroy(stop) | signal*/inject*, DI for pools |
| yours | whatever subscribes + notifies | whatever runs on scope/unmount | match the ecosystem, don't copy React's use prefix |
Semantics to preserve
- Subscribe once per binding, dispose on scope death. One
subscribeper hook call; teardown via the framework's lifecycle (onScopeDispose,onCleanup,$effectcleanup,DestroyRef) — never a manual.destroy()the consumer has to remember. select+SliceOptionspass straight through. Selectors run on every write — document that they must be pure;options.equalsis the rerender gate. React shipsshallowEqual; port it if your ecosystem expects object slices.run/runOncekeep their identity. Return the task's own methods — never wrap in fresh closures per render, or memoized children/API calls destabilize.- Sources are created at call time.
observe()/toTask()inside the adapter (or memoized on first use) — a fresh observable per binding, never a shared one at module scope unless the pattern is explicitly module-scope state. undefinedis a legal render. Components mount before workers bind; templates must tolerate it. On SSR the observable never binds — the fallback is the SSR output.
Optional: pool plumbing
Frameworks with dependency injection or context get a second half: Angular's AtollModule.forRoot/injectAtollPool, React/Vue examples' context-passed clients. The primitives are connectWorker (defineWorker contracts) and connectIslandWorker (island workers) — pass the pool through the framework's own provider mechanism and keep the worker entry bundler-detectable: new Worker(new URL('./x.worker.ts', import.meta.url)) inline.
Package shape
{
"name": "@atolljs/<fw>",
"exports": { ".": "./src/index.ts" },
"peerDependencies": {
"@atolljs/core": "0.1.3",
"<fw>": "^x.y.z" // the framework is the consumer's install
}
}One entry, no build config — packages ship src/ as TypeScript and the consumer's bundler compiles it. Binding code is main-thread only: import type worker definitions, never the module.
Testing
import { InProcessWorker } from '@atolljs/core/testing/inProcessWorker';
vi.stubGlobal('Worker', InProcessWorker);
InProcessWorker.handlerModules = [() => import('./my.worker')];
// real worker registry + real shared memory; only the thread is faked.
// flushObservers() settles pending notifications between assertions.Port a real demo screen (the counter, then a data layer) — the unit tests catch subscription leaks, but only an app catches "re-renders but stale" and "teardown order" bugs.