Skip to content
5 min read · 993 words

Browser: Web Workers

spawnIsolated

spawnIsolated(spec, options) is sugar for building a Web Worker IsolationTransport and handing it straight to createIsolatedService:

ts
import { spawnIsolated } from '@nhtio/adk/batteries/isolation'
import { CounterService } from './spec'

const counter = spawnIsolated(CounterService, {
  worker: new URL('./worker.ts', import.meta.url),
})

const total = await counter.api.increment(5)

new URL('./worker.ts', import.meta.url) is the standard Vite/webpack pattern for pointing a bundler at a worker entry file it should build as a separate chunk. worker.ts itself is an ordinary guest — see the hub page's quick start for the serveIsolated guest shape; it looks identical whether the transport ends up being a Worker or a node child process.

worker: string, URL, or resolver

SpawnIsolatedOptions.worker accepts three shapes:

ShapeBehavior
string | URLcreateWorkerTransport constructs new Worker(url, workerOptions) itself, on every connect() (including every recycle()).
WorkerResolver(ctx: { spec }) => BrowserWorker | Promise<BrowserWorker> — full control over how the Worker is constructed. Called once per connect()/recycle().

Reach for a resolver when a plain URL isn't enough — a worker pool that recycles threads instead of spawning fresh ones, a test harness that builds a Blob-URL worker, or any bundler integration that doesn't fit the new URL(..., import.meta.url) shape:

ts
import { spawnIsolated, type WorkerResolver } from '@nhtio/adk/batteries/isolation'
import { CounterService } from './spec'

const resolve: WorkerResolver = ({ spec }) => {
  console.log(`spawning a worker for "${spec.name}"`)
  return new Worker(new URL('./worker.ts', import.meta.url), { name: spec.name })
}

const counter = spawnIsolated(CounterService, { worker: resolve })

Because a resolver is invoked fresh on every connect(), it is the single source of Worker creation for the service's entire lifetime — every automatic or manual recycle() goes through it too, never a cached instance.

workerOptions and module vs. classic workers

workerOptions is forwarded verbatim to new Worker(url, workerOptions) — it only applies to the string | URL spawn form (a WorkerResolver constructs its own Worker and is responsible for its own options). The default is a classic script (type omitted): that matches this repo's own LiteRT-LM worker prototype, whose Emscripten-generated glue code calls importScripts() — a call that throws inside a { type: 'module' } worker. If your guest script is an ES module, opt in explicitly:

ts
spawnIsolated(CounterService, {
  worker: new URL('./worker.ts', import.meta.url),
  workerOptions: { type: 'module' },
})

transfer(): zero-copy for large payloads

Ordinary arguments and results cross via codec.ts's tiered encoder at whatever tier they need — plain JSON-safe values and the opaque containers (ArrayBuffer/TypedArray/DataView/Date/RegExp/Map/Set) travel through postMessage's structured clone untouched. For large binary payloads (audio frames, a big ArrayBuffer), a structured-clone COPY is still a copy. transfer() marks a value so the browser transport moves it by ownership transfer instead:

ts
import { transfer } from '@nhtio/adk/batteries/isolation'

await counter.api.processAudio(transfer(audioBuffer, [audioBuffer]))

Under the hood, browser.ts's collectTransferables scans every outbound envelope's known WireValue positions (call.args[], stream:start.args[], result.value, stream:delta.delta) for a transfer list and forwards everything it finds as postMessage's second argument. This only matters on the browser transport — node's child_process transport has no such concept and silently ignores the marker, so code written against transfer() behaves correctly (just without the zero-copy benefit) if it's later run over forkIsolated instead.

Crash detection and recovery

A Worker's 'error' event (an uncaught exception escaping the guest's top-level scope) and 'messageerror' event (a received message that failed to deserialize) both surface as an isolation crash: in-flight calls/streams reject with E_ISOLATED_CRASHED, state flips to 'crashed', and every .onCrash(...) subscriber fires with a { reason } describing what happened.

ts
counter.onCrash((info) => {
  console.error(`counter service crashed: ${info.reason}`)
})

Recovery is either manual (await counter.recycle() — terminates the dead worker and reconnects through the exact same WorkerResolver/URL, and every .on(...) subscription plus the service object's identity survive) or automatic via autoRespawn: { policy }, where policy is a createCrashPolicy sliding window: exceed maxCrashes within windowMs and the policy returns 'giveUp' instead of triggering another respawn. This is exactly the crash-escalation ladder the flagship's GpuLossPolicy implements by hand for its WebGPU worker — see Recipes for the isolation-battery equivalent, and the showcase's Surviving the GPU section for the original, hand-rolled story this generalizes.

A disposable Worker is exactly the right response to a wedged GPU context: rather than trying to same-thread dispose() a WebGPU device that may already be in a lost state, recycle() throws the whole Worker away (and, with it, whatever GPU resources it held) and spawns a fresh one.

isolateFunction: the Blob-URL escape hatch

Every other seam in this battery runs a guest script the caller wrote, deployed, and controls the provenance of. isolateFunction is different: it takes an in-memory function VALUE and runs it inside a throwaway Worker without you ever writing a separate guest file.

new Function-based eval trust surface

isolateFunction serializes a function via fn.toString() (through @nhtio/encoder/function_serializer), embeds that source text verbatim into a synthesized Worker script, and has the Worker reconstruct it with new Function(...) at startup. There is no way to do source rehydration without new Function or eval, and this module does not pretend otherwise — that is why the call requires the literal { allowSourceRehydration: true }, enforced both at the type level (a widened boolean fails to compile) and at runtime (so an untyped call site, or an as any cast, cannot skip the acknowledgement). Treat any function you hand to isolateFunction exactly as you would treat a string handed to eval: only ever pass functions whose source your own process produced or controls. fn.toString() captures no closures — only named, module-scope-free source is portable across the Blob boundary.

ts
import { isolateFunction } from '@nhtio/adk/batteries/isolation'

const heavy = isolateFunction(
  (n: number) => {
    let total = 0
    for (let i = 0; i < n; i++) total += Math.sqrt(i)
    return total
  },
  { allowSourceRehydration: true }
)

const result = await heavy.invoke(10_000_000)
heavy.dispose()

Arguments are restricted to the raw codec tier: the Blob guest has no module imports at all (a Blob URL cannot resolve a bare specifier), so it cannot load @nhtio/encoder to decode an escalated (enc: 'nhtio') value. Passing a function, an Error, or a custom-encodable instance as an argument throws E_ISOLATE_FUNCTION_ARG_UNSUPPORTED up front, before anything is even sent — plain, structured-cloneable arguments only.

A guest crash (an uncaught error escaping the rehydrated function, or the rehydrator itself throwing) is PERMANENT: dispose() is not called automatically and no autoRespawn option exists here — this is a one-shot escape hatch, not a managed service. Construct a fresh isolateFunction(...) handle to try again.

Where to go next

  • Node — the equivalent story for out-of-process isolation via child_process.
  • Recipes — a full crash-policy-driven refit of the flagship's LiteRT-LM worker pair onto this substrate.
  • Assembly → Isolation batteries — the full option surface side-by-side with node.