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
| Option | Default | Effect |
|---|---|---|
| Core conversion fields | core | Capabilities, strategy, mentions |
enableMath | true | On unless set false (core defaults off) |
enableInlineMath | false | $x$ is literal text unless opted in |
rendering.concurrency | 2 | Active browser jobs |
rendering.maxQueueSize | 32 | Queued browser jobs |
rendering.launchOptions | — | Executable, sandbox, process options |
rendering.mermaid / .math | safe | Built-in renderer style and limits |
upload.concurrency | 2 | Active uploader calls |
upload.maxQueueSize | 16 | Queued uploader calls |
additionalRenderers | [] | Appended after local Mermaid and KaTeX |
uploader | — | Optional 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:
activeOperationscounts conversions,convertToMessagescalls, and warmups until the underlying work settles, including calls whose prompt timeout already fired.- Render
activecovers browser launch, the job itself, and context cleanup. - Upload
activecounts uploader calls that were actually invoked. - Upload
retainedBytescovers both active and queued image buffers. - A queue's
acceptingis local admission state, not a statement about service health. - Render and upload
acceptingcan stay true while the converter is closing, so operations already accepted can finish scheduling their work. closedmeans every counter is zero.close-failedis 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.