Isolation batteries
The Isolation battery hub page covers the thesis and a quick start; Browser and Node cover each transport's own API in narrative form. This page is the side-by-side option/exception reference — reach for it when you already know which transport you want and need the exact option shape or exception surface.
Barrels, side by side
| Browser | Node | |
|---|---|---|
| Entry point | spawnIsolated | forkIsolated |
| Barrel | @nhtio/adk/batteries/isolation | @nhtio/adk/batteries/isolation/child_process |
| Re-exported from batteries aggregate? | Yes | No — deep import only |
| Transport | Web Worker | node:child_process |
| Transport builder | createWorkerTransport | createChildProcessTransport |
| BYO spawn hook | WorkerResolver | ChildResolver |
| Options type | SpawnIsolatedOptions | ForkIsolatedOptions |
Everything spec-shaped (defineIsolatedService, method, stream, event), the guest server (serveIsolated, serveIsolatedOverPort), createIsolatedService itself, the tiered codec (transfer, registerEncodableClasses), createCrashPolicy, and the observability/exception types all live on the main @nhtio/adk/batteries/isolation barrel — safe to import from either environment. The child_process deep import adds only what actually touches node's own API: forkIsolated, createChildProcessTransport, ChildResolver, and the ForkIsolated*Options types.
IsolatedServiceOptions — shared by every entry point
| Option | Type | Default |
|---|---|---|
readyTimeoutMs | number | 30_000 |
disposeGraceMs | number | 2000 |
autoRespawn | { policy: CrashPolicy } | not set (crashes surface, never auto-heal) |
encodables | ReadonlyArray<{ name: string }> | [] (sugar over @nhtio/encoder's registerClass, lazy) |
onIsolation … onCodecEscalate, debugPayloads | IsolationObservabilityHooks | see Observability below |
createIsolatedService(spec, transport, options?) accepts exactly this shape directly, over any {@link IsolationTransport}; spawnIsolated/forkIsolated extend it with their own transport-selection fields below and strip those transport-only keys before forwarding the rest.
Browser — SpawnIsolatedOptions
| Option | Type | Default |
|---|---|---|
worker | string | URL | WorkerResolver | — (required) |
workerOptions | BrowserWorkerOptions ({ type?, credentials?, name? }) | type omitted (classic script) |
A WorkerResolver — (ctx: { spec }) => BrowserWorker | Promise<BrowserWorker> — is invoked once per connect(), including every recycle(); it is the single source of Worker creation for the service's whole lifetime. workerOptions only applies to the string | URL spawn form.
Node — ForkIsolatedOptions
IsolatedServiceOptions plus exactly one of:
| Variant | Fields |
|---|---|
ForkIsolatedModuleOptions | modulePath: string | URL (required), forkOptions?: child_process.ForkOptions |
ForkIsolatedResolverOptions | spawn: ChildResolver (required) |
Supplying both modulePath and spawn (or neither) throws E_INVALID_ISOLATION_OPTIONS before anything is spawned. Unless forkOptions.serialization is set explicitly, forkIsolated pins serialization: 'advanced' — see Node → why serialization: 'advanced' is pinned for what silently degrades under the node default. A ChildResolver — (ctx: { spec }) => IsolatedChildLike | Promise<IsolatedChildLike> — is invoked once per connect()/recycle(), exactly like a WorkerResolver; the returned value only needs to satisfy IsolatedChildLike (send?, on, off?/removeListener?, kill, connected?).
IsolatedService<S> — what every entry point returns
| Member | Shape | Notes |
|---|---|---|
api | IsolatedFacade<S> | Built once, up front, by iterating spec.methods/spec.streams — not a Proxy. |
on(channel, fn) | (channel, listener) => () => void | Subscriptions survive recycle(). |
onCrash(fn) | (info: CrashInfo) => () => void | |
state | 'starting' | 'ready' | 'crashed' | 'disposed' | |
dispose() | () => Promise<void> | Sends shutdown, waits disposeGraceMs, then forces transport.terminate() regardless. Idempotent. |
recycle() | () => Promise<void> | Terminates and reconnects through the same transport.connect(); object identity and .on(...) subscriptions survive. |
Calls made before the guest's ready envelope arrives queue transparently and flush in order once it does; readyTimeoutMs rejects the connection attempt with E_ISOLATION_READY_TIMEOUT if ready never arrives (and rejects immediately, without waiting out the full timeout, if the guest crashes before ready).
Crash policy — createCrashPolicy
| Option | Type | Default |
|---|---|---|
windowMs | number | 120_000 |
maxCrashes | number | 3 |
now | () => number | Date.now (injectable for tests) |
record() prunes crashes older than windowMs, then returns 'respawn' while the remaining in-window count (including this crash) is still under maxCrashes, 'giveUp' once it reaches it. Two crashes spaced further apart than windowMs are each treated as a fresh, first-in-window crash — an occasional isolated crash over a long-running session never escalates. This generalizes the flagship's GpuLossPolicy (docs/.vitepress/theme/components/agent/gpu_loss_policy.ts) into a domain-neutral, N-rung, pure decider any transport's crash can consult — see the showcase's Surviving the GPU section for the original, hand-rolled 2-rung version this replaces.
Observability
IsolationObservabilityHooks is mixed into every entry point's options (host-side: createIsolatedService, spawnIsolated, forkIsolated; guest-side: serveIsolated, serveIsolatedOverPort). Every reported phase fires the firehose onIsolation AND its matching per-phase-group hook:
| Per-phase-group hook | IsolationReportPhase values |
|---|---|
onSpawn | spawn:start, spawn:ready, spawn:error |
onDispose | dispose:start, dispose:done |
onRecycle | recycle:start, recycle:done |
onCrashReport | crash |
onRespawnAuto | respawn:auto |
onCall | call:start, call:settle |
onStream | stream:start, stream:end, stream:error, stream:cancel |
onAbort | abort:sent |
onWire | wire:out, wire:in |
onCodecEscalate | codec:escalate |
debugPayloads: true (default false) additionally attaches the raw payload body to call:*/wire:* reports (IsolationReport.payload) — a separate opt-in on top of the hooks themselves, since payloads may be large or carry sensitive data. emitIsolationReport skips assembling a report entirely when no relevant hook is registered, so an unhooked service pays zero overhead for this surface. See Recipes → observability for a wired example.
Exceptions
| Exception | Status | Fatal? | Thrown when |
|---|---|---|---|
E_INVALID_ISOLATION_OPTIONS | 529 | Yes | An options bag (spec input, host options, transport-specific spawn shape, crash-policy options) fails eager validation — unknown key, duplicate spec name, or (node) both/neither of modulePath/spawn. |
E_ISOLATION_UNSUPPORTED_ENV | 500 | Yes | serveIsolated() cannot duck-detect a supported guest environment (guest-side only). |
E_ISOLATION_READY_TIMEOUT | 504 | No | The guest's ready envelope never arrived within readyTimeoutMs. |
E_ISOLATED_CRASHED | 503 | No | A call/stream is made (or in flight) after the guest crashed and no autoRespawn/manual recycle() has brought it back. |
E_ISOLATED_TERMINATED | 499 | No | A call/stream was in flight during dispose()/recycle() and got rejected as part of teardown. |
E_ISOLATION_ENCODER_REQUIRED | 528 | No | A value needs to escalate past the raw codec tier (function, Error, custom-encodable) but the optional @nhtio/encoder peer isn't installed. |
E_ISOLATION_UNENCODABLE | 500 | No | A value can't be encoded even with the encoder peer available (e.g. a circular reference that also contains an exotic leaf). |
E_ISOLATE_FUNCTION_REQUIRES_SOURCE_REHYDRATION | 529 | Yes | isolateFunction called without the literal { allowSourceRehydration: true } acknowledgement. |
E_ISOLATE_FUNCTION_UNSERIALIZABLE | 529 | Yes | The function handed to isolateFunction can't be serialized — native and bound functions have no inspectable source. Detected before any Worker is spawned. |
E_ISOLATE_FUNCTION_ARG_UNSUPPORTED | 528 | No | An invoke() argument contains a function/Error/custom-encodable — the Blob guest has no module imports and cannot decode an escalated value; pass only plain, structured-cloneable arguments. |
The fatal members (status 529 or E_ISOLATION_UNSUPPORTED_ENV's 500) are config/environment/call-site bugs raised before anything is spawned — fail loud, not at first use. The rest are non-fatal, raised from an async call/stream/connect path.
Decision tree
- Heavy or untrusted work that must stay reachable from a browser bundle →
spawnIsolated(Web Worker transport). - Same, from node — especially a native-dependency-heavy or crash-prone guest where a process-level fault boundary matters →
forkIsolated(child_processtransport). - A one-off, already-in-memory pure function you want off the main thread without writing a separate guest file, and whose source you trust (your own process produced it) →
isolateFunction— browser-only, permanent-crash (noautoRespawn), not part of thedefineIsolatedServicepipeline. - None of the above → this battery is opt-in; every other battery in this repo works standalone, on the main thread, in the same process.
Where to go next
- Isolation battery — the thesis and quick start.
- Browser —
spawnIsolated,transfer(),isolateFunction, narrative form. - Node —
forkIsolated, the execa hazards, narrative form. - Recipes — the LiteRT-shape refit, isolated embeddings, custom classes across the wire, observability wiring.
- Specialist batteries — the same assembly-guide shape for a different battery domain.