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:

packages/vue/src/index.ts — the template to copy
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

FrameworkReactive primitiveTeardown hookIdiom
React / Next.js clientuseSyncExternalStore(source.subscribe, source.get)the returned unsubscribeuse* hooks
Vueref(source.get()) + write in subscribeonScopeDispose(stop)use* composables returning Refs
SolidcreateSignal(source.get()) → accessoronCleanup(stop)create* factories returning accessors
Svelte 5$state box in a .svelte.ts modulereturn stop from $effectfactories returning getter objects / rune-backed state
Angularsignal(source.get()) + write in subscribeinject(DestroyRef).onDestroy(stop)signal*/inject*, DI for pools
yourswhatever subscribes + notifieswhatever runs on scope/unmountmatch the ecosystem, don't copy React's use prefix

Semantics to preserve

  • Subscribe once per binding, dispose on scope death. Onesubscribe per hook call; teardown via the framework's lifecycle (onScopeDispose, onCleanup, $effect cleanup, DestroyRef) — never a manual .destroy() the consumer has to remember.
  • select + SliceOptions pass straight through. Selectors run on every write — document that they must be pure; options.equals is the rerender gate. React ships shallowEqual; port it if your ecosystem expects object slices.
  • run/runOnce keep 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.
  • undefined is 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

packages/<fw>/package.json
{
  "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.