Skip to Content
API Referencepdf-export-server

@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.

NameTypeDefault
docExportDocJSON
fileNamestring
experimentalExportExperimental

The exporting editor’s experimental features, for the export page (see ExportPayload).

layoutExportLayoutManifest

The exporting editor’s layout. When the export page paginates otherwise, both are logged and the render reports a layout loss; the PDF keeps the export page’s layout either way.

userAgentstring

The exporting browser’s user agent, logged with a layout mismatch.

docIdstring

The host’s id for the document, logged with a layout mismatch.

exportPageUrlstring

Absolute http(s) URL of the export page, e.g. http://localhost:3002/export/print.

signalAbortSignal

Aborts the render: closes the page (interrupting in-flight evaluate / pdf) and rejects with an AbortError. render settles only once its page is closed.

readyTimeoutMsnumber

One deadline from the render call to a ready export page: browser launch, navigation and the readiness wait. Capture comes on top. Defaults to 60s.

captureTimeoutMsnumber

Budget for positioning, printing and merging once the page is ready, on top of readyTimeoutMs. Defaults to 30s + 2s per page.

maxPagesnumber

Cap on captured pages, checked before anything is printed. Embedded PDFs that would push the total past it are left out. Defaults to 500.

maxSubresourceBytesnumber

Per-subresource response cap in bytes. Defaults to 16 MiB.

maxSubresourcesnumber

How many subresources one render may fetch. Defaults to 1000.

maxEmbedBytesnumber

Response cap for one embedded PDF file. Defaults to 50 MiB.

allowedHostsstring[]

Narrows the export page’s requests to its own host plus these host[:port]s. Without it the never-allow class (loopback, private, link-local, cloud metadata, non-http(s)…) is still refused; see buildRequestPolicy . Hosts other than the export page’s own are also checked at the resolved address (DNS rebinding).

The exporter fetches subresources itself, without cookies or authorization, so assets that need credentials must be served unauthenticated.

onTimings(timings: RenderTimings) => void

Called once a render succeeds, with its phase breakdown.

onLosses(losses: ExportLosses) => void

Called once a render succeeds, with what it left out: images that never loaded (often a host outside allowedHosts) and embedded files dropped.

onFields(placements: PdfFieldPlacements) => void

Called once a render succeeds, with where each [data-pdf-field] element (e-signature fields) landed in the PDF. Empty when the export page measured none or measurement failed.

onMeasurements(measurements: unknown) => void

Called once the export page settled, with what its ExportEditorShell measure returned (undefined without one). See PdfExporter.measure.

titlestring | null

The PDF’s /Title. Defaults to the file name without .pdf; null writes none. A redacted export states it, since a file name can name what was redacted.

purgeUnreferencedboolean

Deletes every object nothing references before saving (L21 in docs/partially-executed/redaction-plan.md): annotations stripped from a page, Chromium’s own link targets included, otherwise stay in the file. On for a redacted export.

headlessboolean

Launch headed (for debugging). Defaults to headless unless PDF_EXPORT_HEADED=1.

argsreadonly string[]

Chromium command-line flags. Replaces DEFAULT_CHROMIUM_ARGS.

chromiumSandboxboolean

Run Chromium with its sandbox. Playwright turns it off by default, which is what dev and CI rely on; a server rendering untrusted documents should turn it on. In Docker that needs a seccomp profile allowing user namespaces.

subresourceCacheBytesnumber

Memory for long-lived subresources (hashed JS/CSS chunks, font files) kept across renders; see subresource-cache.ts. Defaults to 64 MiB; 0 turns it off.

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.

Parameters:
NameTypeDefault
options.headlessboolean

Launch headed (for debugging). Defaults to headless unless PDF_EXPORT_HEADED=1.

options.argsreadonly string[]

Chromium command-line flags. Replaces DEFAULT_CHROMIUM_ARGS.

options.chromiumSandboxboolean

Run Chromium with its sandbox. Playwright turns it off by default, which is what dev and CI rely on; a server rendering untrusted documents should turn it on. In Docker that needs a seccomp profile allowing user namespaces.

options.subresourceCacheBytesnumber

Memory for long-lived subresources (hashed JS/CSS chunks, font files) kept across renders; see subresource-cache.ts. Defaults to 64 MiB; 0 turns it off.

Returns:
Promise<PdfExporter>

CreatePdfExporterOptions

Options for createPdfExporter: how the browser is launched, and its subresource cache.

NameTypeDefault
headlessboolean

Launch headed (for debugging). Defaults to headless unless PDF_EXPORT_HEADED=1.

argsreadonly string[]

Chromium command-line flags. Replaces DEFAULT_CHROMIUM_ARGS.

chromiumSandboxboolean

Run Chromium with its sandbox. Playwright turns it off by default, which is what dev and CI rely on; a server rendering untrusted documents should turn it on. In Docker that needs a seccomp profile allowing user namespaces.

subresourceCacheBytesnumber

Memory for long-lived subresources (hashed JS/CSS chunks, font files) kept across renders; see subresource-cache.ts. Defaults to 64 MiB; 0 turns it off.

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.

NameTypeDefault
render(options: RenderOptions) => Promise<Uint8Array<ArrayBufferLike>>

Renders one doc to a PDF using the held browser (fresh context per call).

measure(options: Omit<RenderOptions, "onMeasurements">) => Promise<unknown>

Loads the export page until it settles and resolves with its measurements, printing nothing: a render’s first pass when the document it prints depends on its own layout (a proportional redacted export).

warmup() => Promise<void>

Pre-launches the browser so the first render skips the ~0.3–1s cold start.

close() => Promise<void>

Closes the browser. render rejects afterwards.

RenderOptions

One render: the document, the export page and the limits it runs under.

NameTypeDefault
docExportDocJSON
fileNamestring
experimentalExportExperimental

The exporting editor’s experimental features, for the export page (see ExportPayload).

layoutExportLayoutManifest

The exporting editor’s layout. When the export page paginates otherwise, both are logged and the render reports a layout loss; the PDF keeps the export page’s layout either way.

userAgentstring

The exporting browser’s user agent, logged with a layout mismatch.

docIdstring

The host’s id for the document, logged with a layout mismatch.

exportPageUrlstring

Absolute http(s) URL of the export page, e.g. http://localhost:3002/export/print.

signalAbortSignal

Aborts the render: closes the page (interrupting in-flight evaluate / pdf) and rejects with an AbortError. render settles only once its page is closed.

readyTimeoutMsnumber

One deadline from the render call to a ready export page: browser launch, navigation and the readiness wait. Capture comes on top. Defaults to 60s.

captureTimeoutMsnumber

Budget for positioning, printing and merging once the page is ready, on top of readyTimeoutMs. Defaults to 30s + 2s per page.

maxPagesnumber

Cap on captured pages, checked before anything is printed. Embedded PDFs that would push the total past it are left out. Defaults to 500.

maxSubresourceBytesnumber

Per-subresource response cap in bytes. Defaults to 16 MiB.

maxSubresourcesnumber

How many subresources one render may fetch. Defaults to 1000.

maxEmbedBytesnumber

Response cap for one embedded PDF file. Defaults to 50 MiB.

allowedHostsstring[]

Narrows the export page’s requests to its own host plus these host[:port]s. Without it the never-allow class (loopback, private, link-local, cloud metadata, non-http(s)…) is still refused; see buildRequestPolicy . Hosts other than the export page’s own are also checked at the resolved address (DNS rebinding).

The exporter fetches subresources itself, without cookies or authorization, so assets that need credentials must be served unauthenticated.

onTimings(timings: RenderTimings) => void

Called once a render succeeds, with its phase breakdown.

onLosses(losses: ExportLosses) => void

Called once a render succeeds, with what it left out: images that never loaded (often a host outside allowedHosts) and embedded files dropped.

onFields(placements: PdfFieldPlacements) => void

Called once a render succeeds, with where each [data-pdf-field] element (e-signature fields) landed in the PDF. Empty when the export page measured none or measurement failed.

onMeasurements(measurements: unknown) => void

Called once the export page settled, with what its ExportEditorShell measure returned (undefined without one). See PdfExporter.measure.

titlestring | null

The PDF’s /Title. Defaults to the file name without .pdf; null writes none. A redacted export states it, since a file name can name what was redacted.

purgeUnreferencedboolean

Deletes every object nothing references before saving (L21 in docs/partially-executed/redaction-plan.md): annotations stripped from a page, Chromium’s own link targets included, otherwise stay in the file. On for a redacted export.

RenderTimings

Where one successful render spent its time, in ms.

NameTypeDefault
pagesnumber
totalMsnumber
phasesRecord<string, number>

Server phases: browser, context, goto, ready-wait, embeds, manifest, pages:*, merge.

clientRecord<string, number>

Export page phases: fonts, images, settle, …

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.

NameTypeDefault
typestring

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.

ErrorWhenFields
PageLimitErrorThe laid-out document has more pages than maxPages. The message is safe to show the user (the demo answers 413)pages, maxPages
ExportTimeoutErrorThe export page ran out of time. The message names the export page URL: log it, answer with a generic 504phase

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.

Parameters:
NameTypeDefault
secretstring
__1{ ttlMs?: number; now?: number; }
Returns:
Promise<{ token: string; expiresAt: number; }>

Checks signature and expiry. Single use is the verifier’s job: it keeps the nonce of every accepted ticket until expiresAt.

Parameters:
NameTypeDefault
secretstring
tokenstring
__2{ now?: number; }
Returns:
Promise<ExportTokenVerdict>

What verifyExportToken resolves to.

NameTypeDefault
okboolean

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 those host[: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.

Parameters:
NameTypeDefault
exportPageUrlstring
allowedHostsstring[]
Returns:
(url: string) => RequestDecision

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.

NameTypeDefault
lengthnumber
toString() => string
toLocaleString{ (): string; (locales: string | string[], options?: (NumberFormatOptions & DateTimeFormatOptions) | undefined): string; }
concat{ (...items: ConcatArray<string>[]): string[]; (...items: (string | ConcatArray<string>)[]): string[]; }
join(separator?: string | undefined) => string
slice(start?: number | undefined, end?: number | undefined) => string[]
indexOf(searchElement: string, fromIndex?: number | undefined) => number
lastIndexOf(searchElement: string, fromIndex?: number | undefined) => number
every{ <S extends string>(predicate: (value: string, index: number, array: readonly string[]) => value is S, thisArg?: any): this is readonly S[]; (predicate: (value: string, index: number, array: readonly string[]) => unknown, thisArg?: any): boolean; }
some(predicate: (value: string, index: number, array: readonly string[]) => unknown, thisArg?: any) => boolean
forEach(callbackfn: (value: string, index: number, array: readonly string[]) => void, thisArg?: any) => void
map<U>(callbackfn: (value: string, index: number, array: readonly string[]) => U, thisArg?: any) => U[]
filter{ <S extends string>(predicate: (value: string, index: number, array: readonly string[]) => value is S, thisArg?: any): S[]; (predicate: (value: string, index: number, array: readonly string[]) => unknown, thisArg?: any): string[]; }
reduce{ (callbackfn: (previousValue: string, currentValue: string, currentIndex: number, array: readonly string[]) => string): string; (callbackfn: (previousValue: string, currentValue: string, currentIndex: number, array: readonly string[]) => string, initialValue: string): string; <U>(callbackfn: (previousValue: U, curr...
reduceRight{ (callbackfn: (previousValue: string, currentValue: string, currentIndex: number, array: readonly string[]) => string): string; (callbackfn: (previousValue: string, currentValue: string, currentIndex: number, array: readonly string[]) => string, initialValue: string): string; <U>(callbackfn: (previousValue: U, curr...
find{ <S extends string>(predicate: (value: string, index: number, obj: readonly string[]) => value is S, thisArg?: any): S | undefined; (predicate: (value: string, index: number, obj: readonly string[]) => unknown, thisArg?: any): string | undefined; }
findIndex(predicate: (value: string, index: number, obj: readonly string[]) => unknown, thisArg?: any) => number
entries() => ArrayIterator<[number, string]>
keys() => ArrayIterator<number>
values() => ArrayIterator<string>
includes(searchElement: string, fromIndex?: number | undefined) => boolean
flatMap<U, This = undefined>(callback: (this: This, value: string, index: number, array: string[]) => U | readonly U[], thisArg?: This | undefined) => U[]
flat<A, D extends number = 1>(this: A, depth?: D | undefined) => FlatArray<A, D>[]
at(index: number) => string | undefined
__@iterator@6565() => ArrayIterator<string>
__@unscopables@6567{ readonly [x: number]: boolean | undefined; readonly length?: boolean; toString?: boolean; toLocaleString?: boolean; concat?: boolean; join?: boolean; slice?: boolean; indexOf?: boolean; lastIndexOf?: boolean; ... 17 more ...; readonly [Symbol.unscopables]?: boolean; }

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.

Parameters:
NameTypeDefault
namestring
Returns:
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.

Parameters:
NameTypeDefault
fileNamestring
Returns:
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.

Parameters:
NameTypeDefault
configuredOriginstring
requestOriginstring
Returns:
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.

Parameters:
NameTypeDefault
bodyRequestBodySource
options{ maxBytes: number; }
Returns:
Promise<PdfExportRequest>

PdfExportRequest

The body both export routes accept.

NameTypeDefault
docExportDocJSON
fileNamestring
experimentalExportExperimental
layoutExportLayoutManifest

The requesting editor’s layout, compared with the PDF’s.

docIdstring

The host’s id for the document, for the server log.

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.

Parameters:
NameTypeDefault
bodyRequestBodySource
__1{ maxBytes: number; }
Returns:
Promise<unknown>
Last updated on