Skip to content

@boundsvg/worker ​

Worker adapter for running the WASM engine off the main thread. WorkerEngine mirrors one-shot render and measurement calls. WorkerPool adds ordered, bounded frame streams for animation workloads.

WorkerPool ​

WorkerPool.create(options) ​

Creates independent Worker/WASM instances. Each pool slot receives its own private copy of the supplied fonts, geometries, and symbols, isolated from later caller mutations.

ts
import { WorkerPool } from "@boundsvg/worker";

const fontData = await fetch("/fonts/NotoSansJP.ttf").then((response) =>
  response.arrayBuffer(),
);

const pool = await WorkerPool.create({
  worker: () =>
    new Worker(new URL("@boundsvg/worker/worker", import.meta.url), {
      type: "module",
    }),
  concurrency: 2,
  fonts: [
    {
      alias: "NotoSansJP",
      weight: 400,
      style: "normal",
      data: fontData,
    },
  ],
});
OptionTypeRequiredDefaultDescription
workerURL | WorkerPoolWorkerFactoryYes—Worker module URL or a factory returning a fresh Worker for every pool slot
concurrencynumberNo2Independent Worker count; integer from 1 through 8
fontsFontTransfer[]Yes—Font bytes and descriptors copied into the pool snapshot
geometriesArray<{ id: string; doc: GeometryDoc }>No—Geometry registry snapshot
symbolsArray<{ id: string; def: SymbolDefinition }>No—Symbol registry snapshot
timeoutnumberNo30_000Initialization and individual protocol-call timeout inherited from WorkerEngine

DEFAULT_WORKER_POOL_CONCURRENCY is 2 and MAX_WORKER_POOL_CONCURRENCY is 8. Every slot duplicates WASM state and registered asset memory, so select concurrency from measured memory and startup cost rather than CPU count alone. Mutating the caller's arrays or font buffers after create() starts does not change the pool snapshot.

A local benchmark harness measures the actual WorkerPool path in isolated 1/2/4/8-worker Node processes. It includes the parent font snapshot, per-slot Engine/WASM/font ownership, materialized-scene scheduling and buffering, and SVG/PNG result transfer. Every case has one discarded warmup and five measured repetitions. This is an ownership-topology benchmark, not a claim that every browser has the same RSS. In a reference run, peak RSS grew from roughly 0.1 GiB with one slot to roughly 0.4 GiB with eight slots. Exact values and min/max bands shift with allocator state, runtime version, and hardware. Slots initialize concurrently, so startup is generally flat at low concurrency and the ordering of the one- and two-slot timings carries no signal; contention becomes visible at higher concurrency. The measured memory growth is why the default stays at 2; applications should rerun the benchmark with representative assets before opting into a larger pool.

pool.renderFrames(scene, options) ​

For fixed-layout sampling, each Worker prepares the same scene and samples its assigned times without repeating layout, shaping, outline resolution, or IR parsing for every frame. scene is recursively decoded into a detached SceneNode once before pool fan-out; each active Worker decodes its received copy at its own trust boundary. Convert a VNode with toSceneDocument from @boundsvg/core before starting the stream.

ts
const abortController = new AbortController();

for await (const frame of pool.renderFrames(scene, {
  timesMs: [600, 0, 1_400, 600],
  format: "png",
  signal: abortController.signal,
  onWarning: (warning) => console.warn(warning.code, warning.message),
})) {
  await saveFrame(frame.index, frame.data);
}
ts
type WorkerPoolRenderFramesOptions = Omit<RenderFramesOptions, "timesMs"> & {
  timesMs: readonly number[];
  signal?: AbortSignal;
};
  • Frame index and yield order always follow timesMs input order, regardless of Worker completion order. Duplicate and non-monotonic times are preserved.
  • SVG output is always statically sampled. PNG rasterizes the same static pose.
  • At most one result per active Worker is pending or buffered. A slow consumer therefore applies backpressure bounded by concurrency.
  • Aborting or closing the async iterator starts no new requests and releases every prepared Worker stream. A synchronous WASM render already in progress may finish before its result is discarded.
  • Preparation warnings are collected eagerly and forwarded once before the first frame. All active Workers must return identical warning sequences; divergence is a fatal WORKER_POOL_WARNING_MISMATCH. An empty schedule still prepares one Worker so scene warnings are not lost.

pool.renderMaterializedFrames(source, options) ​

This path accepts a sync or async stream of independently materialized static scenes. Each scene goes through the normal full-scene validation, layout, shaping, and render pipeline in a Worker.

ts
import type { SceneNode } from "@boundsvg/core";
import type { MaterializedFrameInput } from "@boundsvg/worker";

function sceneAt(timeMs: number): SceneNode {
  const progress = Math.min(1, timeMs / 1_000);
  return {
    type: "Canvas",
    width: 640,
    height: 360,
    children: [
      {
        type: "Box",
        width: 120 + 240 * progress,
        height: 160,
        children: [],
      },
    ],
  };
}

function* materialize(
  timesMs: readonly number[],
): Iterable<MaterializedFrameInput> {
  for (const timeMs of timesMs) {
    yield { timeMs, scene: sceneAt(timeMs) };
  }
}

for await (const frame of pool.renderMaterializedFrames(
  materialize([0, 250, 500, 750, 1_000]),
  { format: "svg" },
)) {
  consume(frame);
}
ts
type MaterializedFrameInput = {
  timeMs: number;
  scene: SceneNode;
};

type MaterializedFrameSource =
  | Iterable<MaterializedFrameInput>
  | AsyncIterable<MaterializedFrameInput>;

type WorkerPoolMaterializedFramesOptions =
  | (Omit<RenderSvgOptions, "timeMs"> & { format: "svg"; signal?: AbortSignal })
  | (Omit<RenderPngOptions, "timeMs"> & {
      format: "png";
      signal?: AbortSignal;
    });

The source is consumed lazily and total pending or buffered frames are bounded by concurrency. timeMs is validated after an input is pulled and before it is enqueued. A frame failure closes the whole stream at that index; frames already yielded remain valid, and no later frame is yielded.

Recoverable warnings from each materialized render are forwarded once, immediately before that frame is yielded in input order. Supply onWarning in the format-specific options to receive them. SVG-only namespace/metadata keys are rejected on PNG streams, and raster-only keys are rejected on SVG streams.

Materialized input remains lazy: a scene is not decoded until its frame is pulled. The { timeMs, scene } envelope and time are checked before the Scene value. Structural Scene failures keep their exact Core SCENE_DECODE_* code, message, stage, and context, with only that input's frameIndex added. The same recursive schema, descriptor safety, and resource ceilings apply as for direct Core input; do not rely on JSON.stringify silently dropping unsupported values.

Fixed-layout and materialized rendering ​

PathUse it whenLayout work
renderFramesOne scene has only post-layout animate/animateUnits tracksPrepare once per active Worker, then sample paint poses
renderMaterializedFramesWidth, text, exclusion geometry, or TextOnPath geometry/placement/fit changesFull-scene layout for every materialized scene

renderMaterializedFrames intentionally accepts scenes, not callbacks or timeline tracks. The caller owns interpolation, spring evaluation, schedules, and the purity of its timeMs -> SceneNode generator. See Layout-reactive animation for rigid, reactive, and mixed semantics.

TextOnPath uses the same validation, rich Inline shaping/paint/decoration, stable fatal errors, resolved outlines, and SVG/PNG results through WorkerEngine and WorkerPool as through the direct Node/browser engines. If d, path placement, fit, overflow, content, Inline style/decoration, or font/layout inputs change, put the complete static TextOnPath node in each materialized scene; those fields are not post-layout animation tracks.

Ownership and disposal ​

The pool owns every Worker it uses, whether created from a URL or returned by a factory, plus the Worker engine instances, prepared streams, and copied asset snapshots. Always dispose it when finished:

ts
try {
  // consume one or more frame streams
} finally {
  pool.dispose();
}

dispose() is idempotent and pool[Symbol.dispose]() is equivalent. Disposing the pool rejects active work, closes active sources, releases prepared state, and terminates every Worker. A pool cannot be reused after disposal.

WorkerEngine ​

WorkerEngine.create(options) creates a single-Worker proxy for asynchronous one-shot static SVG, independent animated SVG, PNG, WebP, animated WebP, animated GIF, IR, layered, and text measurement calls. Raster buffers are transferred back without an extra copy. Use WorkerPool when you need ordered multi-frame scheduling; use WorkerEngine when a single off-main-thread engine is sufficient.

Both APIs load assets inside the Worker and must be disposed explicitly. They do not fetch fonts or images implicitly; provide bytes through their options or through an application-owned loading layer. WorkerEngine.create transfers the supplied font ArrayBuffers directly, detaching them on the caller side; copy buffers first if the application must retain them. WorkerPool.create does not detach caller buffers because it snapshots every font before transfer.

Static and animated SVG calls are distinct and match the Core contract:

ts
const still = await workerEngine.renderToSvg(scene, { timeMs: 400 });
const animated = await workerEngine.renderToAnimatedSvg(scene, {
  playback: { mode: "independent" },
  resourceIdPrefix: "worker-0042-",
  nodeIdMetadata: "omit",
});

const stillArtifacts = await workerEngine.renderToSvgAndIR(scene, {
  timeMs: 400,
});
const animatedArtifacts = await workerEngine.renderToAnimatedSvgAndIR(scene, {
  playback: { mode: "independent" },
});

An animated scene sent to a static SVG method requires explicit timeMs. Worker-safe protocol option types omit callbacks that cannot be cloned; WorkerEngine accepts the corresponding Core types, returns warnings in the response, and invokes callbacks on the caller side. Every warning-bearing response carries one top-level serialized recoverable list. SVG-plus-IR responses carry warning-free structural IR on the wire; WorkerEngine rehydrates that list once, retains it in the returned public IR, and gives a detached copy to each callback. Layered responses also use only the top-level list. Structured fatal errors and unknown/legacy option rejection match the direct Engine.

Every public Scene request is detached before it is queued, so mutating the caller's original object after the method returns cannot change the request. The Worker receive boundary independently decodes the transported copy and preserves any Core Scene Fatal instead of replacing it with a generic protocol error. Layout-transition requests decode each of their two states separately; the Worker additionally limits the compact reconstructed two-state envelope to MAX_WORKER_LAYOUT_TRANSITION_PAYLOAD_BYTES (16_777_216).

The protocol accepts only the severity-specific Core diagnostic shapes. A malformed response with a valid pending request ID rejects that request with WORKER_PROTOCOL_INVALID_RESPONSE. A malformed response whose ID cannot be correlated is treated as Worker corruption: the engine is disposed and all pending requests reject. A well-formed late response for an unknown ID is ignored.

Request lifetime and bounded admission ​

A WorkerEngine owns one physical request slot and a FIFO of at most 32 unsent requests. Full admission rejects with WORKER_QUEUE_FULL; a new request never replaces another consumer’s request. Payloads are detached snapshots. The queue limit counts requests and does not cap total heap bytes. Raster streaming has separate one-pull and one-close reservations; font and still-image requests continue through the generic FIFO between animation steps.

The four animated WebP/GIF methods require a sink as their third argument and accept WorkerRequestOptions = { signal?: AbortSignal } as their fourth. Other render methods accept request options as their third argument. The six text measurement methods accept it as their second argument. This transport control is separate from render/measurement data and is never sent to Core or WASM.

ts
const controller = new AbortController();
const svg = await workerEngine.renderToSvg(
  scene,
  { timeMs: 400 },
  {
    signal: controller.signal,
  },
);
const measurement = await workerEngine.measureTextBlock(input, {
  signal: controller.signal,
});

timeout defaults to 30,000ms and accepts only integers from 1 through 2,147,483,647. Null, non-finite values, fractions, and out-of-range values reject with WORKER_INVALID_TIMEOUT before Worker creation. The deadline starts at admission and includes queue wait. Abort or timeout rejects a Promise once; an already posted request keeps the physical slot until its response, a crash, or disposal. A timeout never posts the next job while the old computation is running. Abort does not promise to cancel synchronous WASM.

Animated WebP/GIF writes use the same sink types, entrance snapshots, and result metadata as Core. Normal and layout-transition methods require the sink; they do not return a collected byte array. The sink remains on the caller thread, while the Worker transports one preparation step or bounded output chunk at a time. Warning callbacks run before raster session opening.

For these writes, the absolute deadline continues through a pending sink finish. Finish continuation checks the current clock even if timer delivery was delayed. Once timeout or cancellation rejects the Promise, a later successful rename or close cannot change it to success. An ANIMATED_RASTER_ABORTED diagnostic records the reason and actual operation; operation: "finish" means commitment may be uncertain. Preserve any output that committed later: do not infer that it is safe to delete or retry.

After an early logical rejection, the Engine keeps its animation token through pending callbacks and abort cleanup, so another animation gets ANIMATED_RASTER_JOB_BUSY. Physical stream capacity remains occupied until the remote close acknowledgement, whose timeout starts when close is created. Normal success waits for local cleanup but does not wait for remote close ack; a later close failure prevents future admission without rewriting the completed output's success. Caller-owned Workers are not terminated or automatically recreated on timeout. Cancellation does not immediately interrupt synchronous WASM or a spool's pending finish transfer.

workerEngine.drain(): Promise<void> permanently closes new admission and waits for existing physical work and stream-close acknowledgements. Repeated calls return the same Promise without resetting its deadline. New requests reject with WORKER_ENGINE_DRAINING; expiry rejects with WORKER_DRAIN_TIMEOUT. dispose() is idempotent and settles all local pending work. Only the Worker’s owner terminates it: a Provider or Pool terminates the raw Workers it created, while a WorkerEngine attached to a caller-owned instance does not.

A raw Worker instance belongs to one WorkerEngine lifetime. Simultaneous attachment and attachment after disposal both reject with WORKER_ALREADY_ATTACHED. Create a fresh Worker when recreating an engine, preventing old responses from colliding with reused request IDs.

A WorkerPool keeps its default concurrency of two and maximum of eight. It admits one active frame or materialized-frame operation; another rejects with WORKER_POOL_BUSY without disturbing the first. Creating an iterator does not acquire the operation until iteration starts. Pending plus buffered frames never exceed concurrency, each Worker owns one stream, and one reserved close control slot permits cleanup at full admission. Iterator return, abort, and disposal close streams and release compiled state. The operation remains busy until physical cleanup completes; a cleanup failure makes the pool unusable instead of lending out an uncertain slot.

Worker error codes ​

Failures specific to WorkerEngine and WorkerPool are FatalError instances, but FatalError.code is a string, so use this table as the code inventory. "Retry" describes whether repeating the same operation is appropriate; rows that require a new engine or pool cannot be retried on the disposed or failed instance.

CodeMeaningRetry
WORKER_ALREADY_ATTACHEDA raw Worker was already bound to an Engine lifetime.Create a new Worker instance.
WORKER_DRAIN_TIMEOUTDrain could not confirm physical completion before its deadline.Dispose when graceful completion is unavailable; repeat drain calls share the original Promise.
WORKER_ENGINE_DRAININGTerminal drain has closed admission.Use a new Engine and Worker for new requests.
WORKER_INVALID_TIMEOUTTimeout is outside the integer domain 1 through 2,147,483,647ms.Correct the option before creation.
WORKER_POOL_BUSYThe pool already has an active operation.Finish or close the current iterator before starting another operation.
WORKER_QUEUE_FULLThe Engine already has 32 unsent requests.Submit less work or wait for capacity; existing requests remain unchanged.
WORKER_REQUEST_ABORTEDThe caller's signal aborted this request. Stage is engine; context contains requestId and requestType.Start a new request with a live signal if still needed. Sent work may continue physically.
WORKER_CRASHEDThe Worker emitted an error event and its WorkerEngine was disposed.Create a new engine or pool, then retry. Report repeated crashes.
WORKER_CREATION_FAILEDThe Worker constructor failed, for example because of its URL, CSP, or runtime environment.Fix the environment or Worker URL, then retry creation.
WORKER_ENGINE_DISPOSEDA request targeted a disposed WorkerEngine.Not on that instance; create a new engine.
WORKER_FRAME_ENDPOINT_UNAVAILABLEAn engine without the internal frame endpoint was used by a pool.No automatic retry; recreate the pool and report if it repeats.
WORKER_FRAME_STREAM_CORRUPTA frame stream returned an index or time that does not match its prepared schedule, or the pool's assignment state was inconsistent.No automatic retry; recreate the pool and report the protocol failure.
WORKER_FRAME_STREAM_EXISTSThe Worker received a duplicate open request for an active stream ID.No automatic retry; recreate the pool and report the protocol failure.
WORKER_FRAME_STREAM_NOT_FOUNDThe Worker received a next or close request for an unknown stream ID.Restart the frame operation; report if it repeats.
WORKER_INVALID_MESSAGEThe request envelope is invalid (Worker received an invalid request message., stage engine, no context).Verify matching package versions and rebuild the Worker bundle before retrying.
WORKER_LAYOUT_TRANSITION_INVALIDLayout transition request has an invalid structural shape.Fix the transition envelope, then retry.
WORKER_LAYOUT_TRANSITION_NOT_SERIALIZABLELayout transition request contains an unsafe data value.Fix the bounded reported path and reason, then retry.
WORKER_LAYOUT_TRANSITION_PAYLOAD_LIMITLayout transition request exceeds the Worker payload limit.Reduce the two states or transition data, then retry.
WORKER_MATERIALIZED_FRAME_INVALIDA materialized item is not the required { timeMs, scene } data shape.Fix the item, then retry.
WORKER_MATERIALIZED_SOURCE_NOT_ITERABLEThe materialized frame source is neither an Iterable nor an AsyncIterable.Supply a supported source, then retry.
WORKER_NOT_INITIALIZEDA render request reached the Worker before successful initialization.Initialize through WorkerEngine.create() or recreate the pool, then retry.
WORKER_POOL_ASSET_SNAPSHOT_FAILEDThe pool could not copy a font, geometry, or symbol snapshot.Replace the invalid or detached asset data, then retry creation.
WORKER_POOL_DISPOSEDWork targeted a disposed pool, or active pool work was cancelled by disposal.Not on that pool; create a new pool.
WORKER_POOL_DUPLICATE_WORKERA pool factory returned the same Worker instance for more than one slot.Fix the factory to return a new Worker for every call, then retry creation.
WORKER_POOL_INVALID_CONCURRENCYPool concurrency is not an integer from 1 through 8.Fix concurrency, then retry creation.
WORKER_POOL_WARNING_MISMATCHPool Workers returned different preparation warnings for the same scene.No automatic retry; verify assets and package versions, then recreate the pool.
WORKER_PROTOCOL_INVALID_RESPONSEA response has no valid Worker protocol shape for its request ID.Verify package versions and rebuild the Worker bundle; report if it persists.
WORKER_PROTOCOL_UNEXPECTED_RESPONSEA valid response type does not match the request that was sent.Verify package versions and rebuild the Worker bundle; report if it persists.
WORKER_PROTOCOL_WARNING_SEVERITYThe Worker returned a non-recoverable entry in a warning list.No automatic retry; report the protocol failure.
WORKER_REQUEST_TIMEOUTInitialization or a request exceeded the configured timeout.Check workload and timeout; retry if the render is safe to repeat, or recreate an unhealthy Worker.
WORKER_TRANSPORT_FAILEDpostMessage failed before the request could be transported.Fix invalid or detached transfer data, then retry. Other queued requests remain active.
WORKER_UNHANDLED_ERRORAn exception not represented by a boundsvg FatalError escaped inside the Worker.No automatic retry; inspect the message and report the underlying error.