@stesura/pdf-export-server
Server side of PDF export: loads the export page in headless Chromium, prints
it and assembles the PDF. Node only; the browser side is
@stesura/pdf-export-client, and the
PDF export guide covers setup, egress rules and
limits.
renderDocToPdf
Launches a browser, renders one doc and closes the browser. Takes
RenderDocToPdfArgs (RenderOptions plus CreatePdfExporterOptions) and resolves to the PDF
bytes. For repeated renders, keep an exporter from createPdfExporter.
RenderDocToPdfArgs
What renderDocToPdf takes: one render’s options plus the exporter’s.
createPdfExporter
Keeps one Chromium for many renders, each in a fresh context and page, so
nothing carries over between exports. The browser launches on the first
render (warmup() launches it early) and relaunches if it crashes. Request
interception disables Chromium’s HTTP cache, so the exporter keeps its own:
responses marked immutable or with a max-age of a day or more (hashed JS and
CSS chunks, font files) are served from memory on later renders
(subresourceCacheBytes, 64 MiB by default). onTimings reports where each
render spent its time.
Concurrent render calls are safe but compete for CPU in one browser; queue
them, or run one exporter per worker. Every render is bounded by the timeouts
and caps in RenderOptions, and signal aborts it.
Creates an exporter that owns one long-lived Chromium, launched on the first
render (or by warmup). Each render gets a fresh context and page,
closed when it settles, so no storage carries over between exports.
Only the process stays warm: page.route disables Chromium’s HTTP cache,
so subresources are refetched on every render regardless.
CreatePdfExporterOptions
Options for createPdfExporter: how the browser is launched, and its subresource cache.
DEFAULT_CHROMIUM_ARGS
The Chromium flags an exporter launches with (["--font-render-hinting=none"], so Linux
Chromium wraps text like Windows and macOS browsers). Passing args replaces them: spread
DEFAULT_CHROMIUM_ARGS into your own list to keep them.
PdfExporter
What createPdfExporter resolves to: one warm browser for many renders.
RenderOptions
One render: the document, the export page and the limits it runs under.
RenderTimings
Where one successful render spent its time, in ms.
ExportDocJSON
ProseMirror document JSON (view.state.doc.toJSON()), declared structurally
so the published .d.ts carries no cross-package import — the exporter only
forwards this payload to the export page and never inspects it.
| Name | Type | Default |
|---|---|---|
type | string |
Errors
render and renderDocToPdf reject with these when the document runs into a limit. An aborted
signal rejects with an AbortError, and other failures are plain Errors.
| Error | When | Fields |
|---|---|---|
PageLimitError | The laid-out document has more pages than maxPages. The message is safe to show the user (the demo answers 413) | pages, maxPages |
ExportTimeoutError | The export page ran out of time. The message names the export page URL: log it, answer with a generic 504 | phase |
ExportTimeoutPhase
export type ExportTimeoutPhase = "navigation" | "readiness" | "capture";Where an export ran out of time: loading the export page (navigation), waiting for it to
report ready (readiness, both under readyTimeoutMs), or printing (capture, captureTimeoutMs).
Export tickets (/token)
For a standalone export service that browsers call directly: the app’s server mints a one-use ticket, the service verifies it with the same secret before reading the body. Web Crypto only, and on its own subpath so a token route doesn’t pull in Playwright.
import { mintExportToken, verifyExportToken } from '@stesura/pdf-export-server/token'
// App route
const { token } = await mintExportToken(process.env.PDF_EXPORT_SHARED_SECRET)
// Service: check signature and expiry, then refuse a nonce it has seen
const verdict = await verifyExportToken(secret, token)Tickets live 60 seconds (EXPORT_TOKEN_TTL_MS). verifyExportToken checks the
signature and expiry; making each ticket single-use is the verifier’s job: keep
verdict.nonce until verdict.expiresAt.
Signs a one-use export ticket valid for ttlMs (default EXPORT_TOKEN_TTL_MS). Throws on an empty secret.
| Name | Type | Default |
|---|---|---|
secret | string | |
__1 | { ttlMs?: number; now?: number; } |
Checks signature and expiry. Single use is the verifier’s job: it keeps the
nonce of every accepted ticket until expiresAt.
| Name | Type | Default |
|---|---|---|
secret | string | |
token | string | |
__2 | { now?: number; } |
What verifyExportToken resolves to.
| Name | Type | Default |
|---|---|---|
ok | boolean |
buildRequestPolicy
The egress policy for one render: which URLs the export page may request.
The doc is caller-supplied, so its image srcs are untrusted.
- Always refused:
isEmbedUrlAllowed’s never-allow class (non-http(s), loopback, private and link-local ranges incl. cloud metadata, CGNAT, IPv6 ULA,.local/.internal, unparseable URLs) and dev script hosts. allowedHosts, when given, narrows everything else to thosehost[:port]s.- The export page’s own host is always allowed over http(s): it is first-party and usually loopback, which the never-allow class would refuse.
These are hostname checks. The exporter also applies them to every redirect
hop and checks subresources at the resolved address (DNS rebinding, see
fetchSubresource). The export page’s own navigation gets no address check,
so deployments facing hostile documents still want network-level egress
limits on the Chromium host.
| Name | Type | Default |
|---|---|---|
exportPageUrl | string | |
allowedHosts | string[] |
DEFAULT_EXPORT_ALLOWED_HOSTS
No host besides the export page’s own: the editor fonts ship with the page’s
bundle (@stesura/fonts), so a render needs nothing third-party. Name your
image storage host in allowedHosts, which is the complete allowlist.
RequestDecision
export type RequestDecision = "allow" | "abort";What the request interceptor should do with one request.
sanitizeFileName
Normalizes a caller-supplied name into a safe *.pdf file name, falling
back to document.pdf. The result lands in Content-Disposition and the PDF
title, so quotes, path separators and control characters must not survive.
| Name | Type | Default |
|---|---|---|
name | string |
contentDispositionAttachment
attachment disposition with both RFC 6266 forms: an ASCII filename
fallback (non-ASCII folded to _) and the UTF-8 filename* modern clients
prefer.
| Name | Type | Default |
|---|---|---|
fileName | string |
resolveExportPageOrigin
The origin the export page is loaded from: configuredOrigin
(PDF_EXPORT_PAGE_ORIGIN) when set, otherwise requestOrigin only if it is
loopback.
A request-derived origin trusts the Host header: a forged one would point
the export browser at a host the egress policy then treats as first-party.
Throws rather than guessing; the route should answer 500.
| Name | Type | Default |
|---|---|---|
configuredOrigin | string | |
requestOrigin | string |
readPdfExportRequest
Reads and validates a { doc, fileName?, experimental? } body from the request
stream (a RequestBodySource: a web ReadableStream, or any async iterable of bytes such as a Node
IncomingMessage); experimental keeps known flags only, as booleans. Use it
instead of await request.json(), which buffers the whole body before any cap
applies; this stops reading at maxBytes. Throws PdfExportRequestError,
whose status is the one to answer with: 413 for too large, 400 for
malformed.
Reads and validates a { doc, fileName? } body. Throws PdfExportRequestError.
| Name | Type | Default |
|---|---|---|
body | RequestBodySource | |
options | { maxBytes: number; } |
PdfExportRequest
The body both export routes accept.
readJsonBody
The same capped read without the doc validation, for a route with its own body shape.
Reads a JSON body, stopping as soon as it passes maxBytes.
request.json() would buffer the whole body before any cap could apply.
| Name | Type | Default |
|---|---|---|
body | RequestBodySource | |
__1 | { maxBytes: number; } |