Quickstart

A minimal counter: one shared field, one worker method, one component. Four files, and the method name is written exactly once.

1 · Install

npm install @atolljs/core @atolljs/react

2 · Declare shared memory — imported by both threads

counter.memory.ts
import { defineSharedMemory, field } from '@atolljs/core';

export const counterMemory = defineSharedMemory({
  count: field.number(),
});

3 · Define the worker — methods live here

counter.worker.ts
import { defineWorker } from '@atolljs/core';
import { counterMemory } from './counter.memory';

// defineWorker wires the message loop and registers every method.
// Plain functions type the client from their signature.
export const counterWorker = defineWorker({
  sharedMemory: counterMemory,
  methods: {
    increment(delta: number) {
      const next = counterMemory.count.read() + delta;
      counterMemory.count.write(next);  // write in place — no postMessage
      return next;
    },
  },
});
export type CounterWorker = typeof counterWorker;

4 · Connect from the main thread — type only

counter.ts
import { connectWorker } from '@atolljs/core';
import { counterMemory } from './counter.memory';
import type { CounterWorker } from './counter.worker';  // no worker code in this bundle

export const counter = connectWorker<CounterWorker>({
  sharedMemory: counterMemory,
  // Inline new Worker(new URL(..., import.meta.url)) — every bundler's
  // worker transform can see the entry point this way.
  worker: () => new Worker(new URL('./counter.worker.ts', import.meta.url), { type: 'module' }),
  poolSize: 'auto',   // navigator.hardwareConcurrency, or pass a number
});
// counter.increment(1) → Promise<number>. The pool spawns on first call
// (SSR-safe to import); counter.terminate() tears it down.

5 · Bind it in your framework

App.tsx
import { useSharedValue, useTask } from '@atolljs/react';
import { counterMemory } from './counter.memory';
import { counter } from './counter';

export function App() {
  const count = useSharedValue(counterMemory, 'count');
  const increment = useTask(counter.increment);   // any async fn → latest-wins task state
  return (
    <button onClick={() => increment.run(1)}>count: {count ?? '…'}</button>
  );
}

That's the whole loop — increment.run(1) posts the call to a worker, the worker writes count in place, and the binding re-renders on the next field write. No serialization of the value itself. Need validation at the boundary? Swap the plain function for a serviceMethod({ def: { argsSchema, resultSchema }, run }) unit — see Worker pool & tasks.

Shared memory is opt-in

Leave sharedMemory out of both defineWorker and connectWorker and you have a typed, pooled, cancellable worker RPC that runs anywhere Workers do — no isolation headers needed. Add the contract when a worker owns state the UI should observe without copying.

With shared memory: cross-origin isolation

SharedArrayBuffer only exists when the page is cross-origin isolated. Vite example:

vite.config.ts
export default defineConfig({
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
});

Production hosting needs the same headers — see Hosting & headers.