Skip to content
7 min read · 1,432 words

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

BrowserNode
Entry pointspawnIsolatedforkIsolated
Barrel@nhtio/adk/batteries/isolation@nhtio/adk/batteries/isolation/child_process
Re-exported from batteries aggregate?YesNo — deep import only
TransportWeb Workernode:child_process
Transport buildercreateWorkerTransportcreateChildProcessTransport
BYO spawn hookWorkerResolverChildResolver
Options typeSpawnIsolatedOptionsForkIsolatedOptions

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

OptionTypeDefault
readyTimeoutMsnumber30_000
disposeGraceMsnumber2000
autoRespawn{ policy: CrashPolicy }not set (crashes surface, never auto-heal)
encodablesReadonlyArray<{ name: string }>[] (sugar over @nhtio/encoder's registerClass, lazy)
onIsolationonCodecEscalate, debugPayloadsIsolationObservabilityHookssee 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

OptionTypeDefault
workerstring | URL | WorkerResolver— (required)
workerOptionsBrowserWorkerOptions ({ 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:

VariantFields
ForkIsolatedModuleOptionsmodulePath: string | URL (required), forkOptions?: child_process.ForkOptions
ForkIsolatedResolverOptionsspawn: 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

MemberShapeNotes
apiIsolatedFacade<S>Built once, up front, by iterating spec.methods/spec.streams — not a Proxy.
on(channel, fn)(channel, listener) => () => voidSubscriptions 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

OptionTypeDefault
windowMsnumber120_000
maxCrashesnumber3
now() => numberDate.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 hookIsolationReportPhase values
onSpawnspawn:start, spawn:ready, spawn:error
onDisposedispose:start, dispose:done
onRecyclerecycle:start, recycle:done
onCrashReportcrash
onRespawnAutorespawn:auto
onCallcall:start, call:settle
onStreamstream:start, stream:end, stream:error, stream:cancel
onAbortabort:sent
onWirewire:out, wire:in
onCodecEscalatecodec: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

ExceptionStatusFatal?Thrown when
E_INVALID_ISOLATION_OPTIONS529YesAn 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_ENV500YesserveIsolated() cannot duck-detect a supported guest environment (guest-side only).
E_ISOLATION_READY_TIMEOUT504NoThe guest's ready envelope never arrived within readyTimeoutMs.
E_ISOLATED_CRASHED503NoA call/stream is made (or in flight) after the guest crashed and no autoRespawn/manual recycle() has brought it back.
E_ISOLATED_TERMINATED499NoA call/stream was in flight during dispose()/recycle() and got rejected as part of teardown.
E_ISOLATION_ENCODER_REQUIRED528NoA value needs to escalate past the raw codec tier (function, Error, custom-encodable) but the optional @nhtio/encoder peer isn't installed.
E_ISOLATION_UNENCODABLE500NoA 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_REHYDRATION529YesisolateFunction called without the literal { allowSourceRehydration: true } acknowledgement.
E_ISOLATE_FUNCTION_UNSERIALIZABLE529YesThe 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_UNSUPPORTED528NoAn 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_process transport).
  • 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 (no autoRespawn), not part of the defineIsolatedService pipeline.
  • 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.
  • BrowserspawnIsolated, transfer(), isolateFunction, narrative form.
  • NodeforkIsolated, 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.