Node converter

function createNodeConverter(options?: CreateNodeConverterOptions): NodeConverter;

The factory is lazy. It owns the local Mermaid and KaTeX renderers plus one shared Chromium runtime, and nothing starts until the first call that needs it.

Factory

OptionDefaultEffect
Core conversion fieldscoreCapabilities, strategy, mentions
enableMathtrueOn unless set false (core defaults off)
enableInlineMathfalse$x$ is literal text unless opted in
rendering.concurrency2Active browser jobs
rendering.maxQueueSize32Queued browser jobs
rendering.launchOptionsExecutable, sandbox, process options
rendering.mermaid / .mathsafeBuilt-in renderer style and limits
upload.concurrency2Active uploader calls
upload.maxQueueSize16Queued uploader calls
additionalRenderers[]Appended after local Mermaid and KaTeX
uploaderOptional default; per-call uploader wins

At the 10 MiB per-image limit, default upload admission can retain up to 180 MiB (2 active + 16 queued). This is a queue buffer bound, not a process, request, or workflow memory bound.

Core fields go straight on the factory. There is no conversionDefaults wrapper, and upload scheduling is tracked separately from browser rendering. Duplicate renderer IDs throw ConfigurationError, collisions with mermaid or katex included.

Uploaders you pass, at the factory or per call, are borrowed rather than owned: closing the converter never closes them. The factory and the exported low-level constructors validate their own options synchronously, while convert, convertToMessages, warmup, and close reject with ConfigurationError when the call options are invalid.

Calls

interface OperationOptions {
  readonly signal?: AbortSignal;
  /** Relative milliseconds from call start. */
  readonly timeoutMs?: number;
}
const result = await converter.convert(markdown, {
  uploader,
  signal,
  timeoutMs: 15_000,
});

Both signal and timeoutMs are optional, and neither can be a reusable factory default, because a relative timeout only means something from the moment a call starts. convertToMessages takes the same options, and warmup({ signal?, timeoutMs? }) goes through the same bounded render queue. Neither convert nor warmup has a default timeout, so supply one if you need a ceiling.

Warmup launches a browser and a context and loads the Mermaid and KaTeX assets. It renders nothing, takes no screenshot, uploads nothing, posts nothing, and proves nothing about what Slack will show.

Close and status

  • close({ behavior?: "drain" | "cancel", signal?, timeoutMs? })
  • status(): NodeConverterStatus

close() defaults to a finite bounded drain and is idempotent, and it stops admission the moment you call it. Cancellation is cooperative, so you can get a prompt return while collaborator promises nobody is waiting on any more stay tracked.

status() returns:

interface NodeConverterStatus {
  readonly state: "open" | "closing" | "closed" | "close-failed";
  readonly activeOperations: number;
  readonly render: {
    readonly accepting: boolean;
    readonly active: number;
    readonly queued: number;
    readonly retainedBytes: number;
  };
  readonly upload: {
    readonly accepting: boolean;
    readonly active: number;
    readonly queued: number;
    readonly retainedBytes: number;
  };
}

The first valid close call wins and every later caller gets its promise. Success ends at closed. Any accepted close failure ends permanently at close-failed, caller abort included, even if the counters later reach zero, and admission stays shut either way.

Default close() drains for 5 seconds; async disposal cancels with the same 5-second budget. Internally, browser launch is capped at 10 seconds and cleanup at 2.

Counter invariants:

  • activeOperations counts conversions, convertToMessages calls, and warmups until the underlying work settles, including calls whose prompt timeout already fired.
  • Render active covers browser launch, the job itself, and context cleanup.
  • Upload active counts uploader calls that were actually invoked.
  • Upload retainedBytes covers both active and queued image buffers.
  • A queue's accepting is local admission state, not a statement about service health.
  • Render and upload accepting can stay true while the converter is closing, so operations already accepted can finish scheduling their work.
  • closed means every counter is zero. close-failed is a terminal historical state, and stays that way even after the counters drain.

Errors and receipts

  • ConfigurationError: invalid options, collaborators, IDs, or output descriptors.
  • OperationAbortedError: caller signal.
  • OperationDeadlineExceededError: call timeout.
  • OperationShutdownError: admission or cooperative cancellation during shutdown.
  • NodeShutdownTimeoutError: underlying work did not settle before close timeout.
  • NodeRenderError: a browser, asset, render, or cleanup failure. A browser disconnect is retryable on a fresh browser.

Test these with isConfigurationError / isOperationError from slackmark rather than instanceof, and narrow operation errors on code. The published entries are bundled separately, so an error raised inside one entry does not satisfy instanceof against a class imported from another. See Validating output.

NodeRenderStage is closed to admission, asset_load, browser, browser_launch, browser_cleanup, browser_close, context_create, context_cleanup, layout, output_validation, render, and source_validation. Custom universal StructuredRendererError stages remain open strings.

Operational errors are never degradations. degradations describe content changes; receipts record upload effects, and they ride along on thrown errors too. An error thrown before the work settles carries a frozen receipts snapshot plus settledReceipts: Promise<ReadonlyArray<UploadReceipt>> holding that operation's final receipts. That promise never rejects, and it can stay pending forever if a collaborator never settles, so bound how long you wait on it.

Only NodeShutdownTimeoutError includes a status snapshot. Ordinary NodeRenderError cleanup failures do not.