Production

This page is about running Chromium-backed rasterization in a real service: getting a browser onto the box, keeping its lifecycle bounded, and not lying to yourself about what cancellation can actually stop.

Chromium

Puppeteer downloads Chrome and Chrome Headless Shell, so cache Puppeteer's browser directory in CI. In containers you also need Chromium's shared libraries and a usable sandbox. Reach for --no-sandbox only when the host or container already gives you equivalent isolation.

A system or serverless Chromium can be selected with rendering.launchOptions.executablePath. Workers, browsers, edge isolates, and Bun cannot run slackmark-node. Universal slackmark remains portable there.

Long-lived process

import { FetchSlackUploader } from "slackmark/slack";
import { createNodeConverter } from "slackmark-node";

const converter = createNodeConverter({
  uploader: new FetchSlackUploader({ token: process.env["SLACK_BOT_TOKEN"] ?? "" }),
  rendering: { concurrency: 2, maxQueueSize: 32 },
  upload: { concurrency: 2, maxQueueSize: 16 },
});

await converter.warmup({ timeoutMs: 30_000 });

export async function shutdown(): Promise<void> {
  await converter.close({ behavior: "drain", timeoutMs: 5_000 });
}

The built-in Mermaid and KaTeX renderers return bytes, so an uploader is required. Without one, the first diagram or display-math block rejects the whole conversion with a ConfigurationError. Register shutdown with a framework hook that awaits returned promises. Do not fire and forget close().

You own shutdown order: stop ingress, settle or cancel in-flight request and post work, then close the converter. Preserve both the primary and close errors (for example with AggregateError) instead of allowing finally to hide one.

close rejects new operations immediately. What happens to work already accepted depends on the behavior you pass:

  • drain lets accepted work finish.
  • cancel signals accepted work to stop cooperatively.
  • On timeout you get NodeShutdownTimeoutError, carrying a status() snapshot and the receipts known at that point.

JavaScript cannot kill an arbitrary promise. Renderer/upload slots and retained upload bytes remain in status() until the underlying collaborator settles. Async disposal uses a finite cancel timeout.

status() is accounting, not a health signal.

Deployment lifetimes

  • Long-lived service: one converter per process; warm once; close during awaited shutdown.
  • Warm-cached serverless: cache the converter outside the handler and do not close it after each invocation.
  • True one-shot: create once inside the invocation, close once in finally, and budget for every Chromium cold start.

Your app sets its own ceilings: how many bytes and how much concurrency it accepts on the way in, how many messages a split may produce, how many times to retry a render or an upload, how many uploads in total, and what its posting policy is. slackmark deliberately imposes no global workflow quotas.

Timeouts and retry

timeoutMs is relative to each call and signal is optional. Internally the two collapse into a single absolute OperationContext that renderers and uploaders share.

  • Do not retry source, dimension, output, or invalid-result failures.
  • Conversion retryable metadata and upload receipt retrySafety are different signals. Never retry upload when safety is "never"; reconcile "may-duplicate" first.
  • Posting follows the same rule as everywhere else: retry a definite rejection if your policy allows, reconcile or accept a duplicate after a timeout or lost response.
  • A completed upload with processing: "unverified" is not a reason to upload again.
  • NodeShutdownTimeoutError and close-failed are terminal for that converter instance.

Prompt operation errors contain a frozen receipt snapshot. settledReceipts provides eventual final receipts for that operation, never rejects, and can remain pending if a collaborator never settles. Bound the wait with your own policy.

See Node converter for lifecycle errors and status fields.