npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

typst-report

v0.2.0

Published

A suite for building editorial reports with Typst — templates, layout primitives and multi-format output

Readme

typst-report

checks npm

Editorial-grade reports for Node.js, built on Typst.

Define a report in TypeScript and render it to PDF, PNG or SVG — or write your own Typst template on top of the shipped libraries. Tables that repeat their headers, books with computed tables of contents, charts, pivots, record sheets, math — with real typography.


Contents


Why

Node has no editorial layout engine.

| Approach | What breaks | | ---------------------- | --------------------------------------------------------------------------------- | | Puppeteer / Playwright | an entire browser per PDF; partial CSS Paged Media; page-break control is a fight | | PDFKit, pdfmake, jsPDF | imperative drawing, no layout engine | | @react-pdf/renderer | a flexbox subset, limited typography |

None of them can produce a table of contents with computed page numbers, control widows and orphans, or set real typography. Typst can — but it is a binary, not a library. This package is both halves of the bridge: a safe engine around the binary, and the report vocabulary (columns, groups, totals, sheets, chapters, themes) that every project otherwise rebuilds by hand.

Requirements

  • Node.js ≥ 22
  • A typst binary ≥ 0.15 — this package does not bundle or download one

The binary is a parameter on purpose. Typst is pre-1.0 and its layout changes between minor versions; every template is effectively a validated visual snapshot, and you decide when that moves. Get a binary from the official releases, brew install typst, cargo install typst-cli, or the ghcr.io/typst/typst Docker image.

Install

npm install typst-report

Quick start

import { TypstRenderer, defineReport } from 'typst-report'

const renderer = new TypstRenderer({ bin: '/usr/local/bin/typst' })

const report = defineReport({
  title: 'Sales by region',
  columns: [
    'product', // shorthand: id is enough
    { id: 'amount', label: 'Amount', kind: 'currency' },
    { id: 'soldAt', label: 'Date', kind: 'date' },
  ],
  groupBy: 'region',
  totals: { amount: 'sum' },
  locale: { locale: 'en-US', currency: 'USD' },
})

const pdf = await report.toPdf(rows, { renderer }) // Buffer
const draft = await report.toPdf(rows, { renderer, watermark: 'DRAFT' }) // stamped for review

That already gives you: a column header that repeats across page breaks, group headers with subtotals, a grand total on the last page only, zebra striping, a page X of Y footer, and locale-aware formatting.

The five definitions

Every definition follows the same rules:

  1. Zero ceremony for the default case — shorthands everywhere, defaults by kind.
  2. One escape hatch, always the same onetoDocument() hands you the generated Typst source and data when the shape runs out.
  3. Every output from one definition — PDF, draft, PNG thumbnail must never require restating the report.

defineReport — tabular listing

defineReport({
  title,
  subtitle,
  contextLine: 'filters: 2024–2026 · status: all', // what produced the data
  columns: [
    'id-only',
    {
      id: 'x',
      label: 'X',
      kind: 'number' | 'currency' | 'percent' | 'date' | 'text',
      value: (row) => row.nested.thing, // defaults to row[id]
      align: 'left' | 'right' | 'center',
    }, // defaults by kind
  ],
  groupBy: 'columnId' | ((row) => string),
  totals: { columnId: 'sum' | 'count' | 'avg' | 'min' | 'max' },
  watermark: 'DRAFT', // default stamp; per-render override in toPdf
  locale: { locale: 'pt-BR', currency: 'BRL' }, // the defaults
})
  • watermark in the definition stamps every render; passing it in toPdf overrides per render (null removes it) — the draft-vs-issued flow.
  • Aggregations are computed in TypeScript (the data is already materialised); the template only places them.
  • No data exports. dados → CSV/XLSX is data serialisation, not TypeScript → Typst; pair the definition with csv-stringify or friends. (Typst itself only reads CSV — csv() — it never writes it.)

defineSheet — one record, one page

The record archetype: a header band with a badge, labelled blocks, an aside rail of short facts. A product one-pager, a personnel record, a certificate.

const sheet = defineSheet<Tech>({
  title: 'title',
  badge: (tech) => `TRL ${tech.maturity}/9`,
  blocks: [
    { label: 'The problem', value: 'problem' },
    { label: 'Applications', value: (tech) => tech.applications }, // array → bullet list
  ],
  aside: [{ label: 'Inventors', value: 'inventors' }],
})

await sheet.toPdf(records, { renderer }) // one page per record
sheet.toRecords(records) // the data, for embedding in a book

defineBook — cover, TOC, chapters

The catalogue archetype. A SheetDefinition embeds as the chapter detail — that is the subreport case, by composition:

const book = defineBook<Tech>({
  cover: {
    eyebrow: 'Innovation Department',
    title: 'Technology Portfolio',
    subtitle: 'Protected and ready for transfer',
    edition: 'Edition 2026',
  },
  toc: true, // computed page numbers
  chapters: {
    groupBy: 'area',
    description: (label, rows) => `${rows.length} technologies in this chapter.`,
  },
  sheet: {/* a SheetDefinition */},
  closing: { title: 'Innovate with us', text: 'Licensing and R&D partnerships welcome.' },
})

await book.toPdf(rows, { renderer, theme })

Add a record and the TOC repaginates itself — page numbers are computed by the layout engine, not bookkept by you.

defineCrosstab — pivot

Pivoting happens in TypeScript; rendering goes through the same table and page chrome as every report.

const pivot = defineCrosstab({
  title: 'Sales — region × month',
  rows: 'region',
  cols: (row) => row.soldAt.slice(0, 7), // derive the bucket
  cell: { value: 'amount', aggregate: 'sum', kind: 'currency' },
  margins: true, // row/column totals + grand total
  maxCols: 14, // wider than this REFUSES, loudly
})

await pivot.toPdf(rows, { renderer })
pivot.toMatrix(rows) // the raw numbers — serialise them however you like

A pivot too wide to read fails with a RangeError telling you to coarsen the bucket — refusing honestly beats shrinking silently.

chart — data to image

Bar, line and donut, drawn by the shipped report/chart.typ with Typst primitives — no Universe dependency, compiles stay hermetic. Axis tops land on round numbers (1/2/5 × 10ⁿ).

const png = await chart({
  type: 'bar' | 'line' | 'donut',
  title: 'Sales by region',
  data: [{ label: 'South', value: 32651 } /* … */],
  values: 'compact', // "32.7k" ticks
}).toPng({ renderer, ppi: 300 })

Also toSvg and toPdf. Inside a custom template, import the same primitives directly (bar-chart, line-chart, donut-chart).

Themes — brand as a parameter

A report suite must have layout opinions or it is not a suite; it must not have brand opinions or nobody can use it twice. The theme is the seam:

const theme: Theme = {
  tokens: {
    colors: {
      primary: '#008476',
      ink: '#0E1925',
      muted: '#727376',
      surface: '#F0F4F3',
      line: '#E4E5E8',
    },
    fonts: { sans: 'Lato' },
  },
  assets: { 'fonts/Lato-Regular.ttf': await readFile('…') },
}

await report.toPdf(rows, { renderer, theme })

Files under fonts/ automatically join --font-path, so a theme carries its own typefaces. Naming a family it does not ship is a bug in the theme, not a silent fallback.

Custom mode — your template

When the shapes run out, write Typst. Same engine, same data contract:

await renderer.compile({ source, data }) // template as a string
await renderer.compile({ template: './report.typ', data }) // or a file on disk

data becomes data.json in the render directory; the template reads it with #let d = json("data.json"). A template stored in a database and a .typ owned by a designer are the same call.

What the template sees

Every render happens inside an ephemeral directory, which is also the compile --root. The conventions, in one place:

| Inside the render directory | What it is | The template says | | --------------------------------- | -------------------------------------------------------------- | --------------------------------------------------- | | main.typ | your template file or source string | — (it is this file) | | data.json | data, serialised | json("data.json") | | report/*.typ | the shipped libraries, always present | #import "report/table.typ" | | your libraries / files keys | placed verbatim at their key's path | #import "acme/brand.typ", image("img/logo.svg") | | fonts/** | any files under fonts/ — the directory joins --font-path | just name the family | | out.pdf / out-{n}.png | the output — collected for you | — |

Defensive data access

Real-world JSON has absent fields, and in Typst data.at("missing") kills the compile — in production, one nullable field away from a 500. The shipped report/data.typ exists for exactly this (custom templates included — it is not a kit internal):

#import "report/data.typ": get, has, require, non-empty

#let d = json("data.json")
#let title = require(d, "title")                 // mandatory: fail loudly, name the field
#let badge = get(d, "meta.badge", default: none) // optional: absence is a value
#if has(d, "cover") [ #image(d.cover) ]

Shipped Typst libraries

Placed into every render automatically — #import and go:

| Library | What it gives | | ------------------- | ---------------------------------------------------------------------------------------------- | | report/data.typ | get(d, "a.b.c", default: …), has, non-empty, require — absence is a value, not a crash | | report/format.typ | date-br, date-long-br, number-br, currency-br, percent-br, cpf, cnpj | | report/table.typ | report-table — repeating header, group ruptures, non-repeating grand total, zebra | | report/page.typ | report-page — title block, context line, numbered footer | | report/sheet.typ | record-sheet — the record archetype | | report/chart.typ | bar-chart, line-chart, donut-chart, compact-number |

Two hard-won rules encoded in them: data.at("missing") kills a compile, so data access goes through get/require; and Typst's table.footer repeats on every page by default, which is right for chrome and wrong for totals.

Your own libraries

Three tiers — the third being Universe packages, below (see examples/17-custom-libraries.ts):

// 1. Registered once — travels with EVERY compile, like the shipped libraries
const renderer = new TypstRenderer({
  bin: 'typst',
  libraries: {
    'acme/brand.typ': { path: './brand/acme.typ' }, // a file on disk
    'acme/motto.typ': '#let motto = "own the seam"', // string = contents
    'img/logo.svg': logoBuffer, // Buffer = contents
  },
})

// 2. Per compile — this document only; same key overrides tier 1
await renderer.compile({ source, data, files: { 'acme/brand.typ': other } })

Everywhere a file is given — libraries and files — the same forms are accepted. Bytes are any Uint8Array — a Buffer, but also what a web API or database driver hands back, no re-wrapping. A string is always contents, never a path; a path is always { path } — nothing is ambiguous. { path } libraries are read lazily on the first compile and cached for the renderer's lifetime; { path } entries in per-compile files are read fresh on every render.

Files the renderer fetches for you

A fourth form is a function, and it is the one that scales. The workdir is filled one entry at a time, in order, and each function is called only when its turn comes:

// `store` is whatever holds your bytes — an S3 client, a blob store, a
// filesystem wrapper. The library never learns which; it just calls the
// function when the file's turn comes.
await renderer.compile({
  template,
  data,
  files: Object.fromEntries(
    covers.map((c) => [`img/cover-${c.id}.png`, () => store.getBytes(c.key)])
  ),
})

A Buffer you build up front has to exist before compile() is called — all of them at once, however many there are. A function is asked for its bytes at the moment they are written and they are garbage the moment after, so a render's peak memory is one file rather than N. For 69 covers of 5 MiB, that is the difference between a 411 MiB peak and a 137 MiB one; the eager cost tracks the total, the lazy cost does not.

The function may return anything the eager forms allow, plus a stream — useful when one file is large enough that even holding it whole is too much. Any async iterable of bytes qualifies: a Node Readable, a web ReadableStream (what fetch() and modern storage SDKs return), an async generator — no adapter at the boundary:

files['img/poster.png'] = () => store.getStream(key) // never fully resident
files['img/chart.png'] = async () => (await fetch(url)).body! // a web stream, as-is

A bare stream — await store.getStream(key), without the arrow — is refused with InvalidFileSourceError. A stream can be consumed once, while files is read on every compile, so the second render would write an empty file and lose the image with no error anywhere. The function form is what makes each render get a fresh stream. A stream that fails mid-read — the connection that dies after the function returned — is reported as TypstFileThunkError under the same key, exactly as if the fetch had failed up front.

Two properties worth relying on:

  • Cancellation reaches the fetches. The function receives the compile's own signal, and it is checked between entries: a client that disconnects half-way through stops the downloads that have not happened yet, which in this shape is most of the render's real cost. That is the preparing phase of TypstAbortedError.
  • libraries functions are called once. They are resolved on the first compile and cached like every other library. The type says so — a library function takes no arguments, because no single compile owns its result, so there is no signal to hand it.
#import "acme/brand.typ": acme-callout

Typst Universe packages

#import "@preview/cetz:0.3.1"   // works — and downloads over the network

Typst Universe imports work out of the box, at a cost: the compile reaches the network and a cache outside the render directory. For production, pre-warm a cache once (compile with network available) and pin it:

new TypstRenderer({
  packageCachePath: '/opt/typst-cache', // pinned @preview download cache
  packagePath: '/opt/local-packages', // your @local namespace packages
})

After that, compiles never touch the network. The shipped libraries never import from Universe, by rule.

The engine

const renderer = new TypstRenderer({
  bin: '/path/to/typst', // required
  fontPath: './fonts', // optional
  ignoreSystemFonts: true, // default — reproducibility
  libraries: {/* your .typ */}, // optional — see above
  packagePath: '/local-packages', // optional
  packageCachePath: '/typst-cache', // optional
  concurrency: 1, // default — compiles per process at once
  timeoutMs: 15_000, // SIGKILL ceiling per compile
})

Construct one renderer per process and share it. The concurrency gate lives in the instance — a renderer constructed inside a request handler gets a fresh gate every time, and eight concurrent requests become eight concurrent Typst processes on a host you sized for one. A module-level export const renderer = new TypstRenderer({...}) is a process-wide singleton by virtue of ESM module caching; that is the intended shape. ({ path } libraries exist precisely so this construction never needs to be async.)

Output formats

await renderer.compile({ source, data }) // Buffer — pdf
await renderer.compile({ source, data, format: 'png', ppi: 144 }) // Buffer[] — one per page
await renderer.compile({ source, data, format: 'png', pages: [1] }) // cover thumbnail
await renderer.compile({ source, data, format: 'svg' }) // Buffer[]
await renderer.compile({ source, data, format: 'html' }) // experimental, gated by Typst

pdf and html resolve to a single Buffer; png and svg to one per page — the overloads encode that, so TypeScript knows which you got.

Cancellation

Every compile accepts an AbortSignal. Wire the HTTP connection's fate in and a closed tab stops costing anything:

import type { ServerResponse } from 'node:http'

// `close` also fires after a *successful* delivery — check writableFinished,
// or every completed download is recorded as a client abort.
function abortSignalFor(response: ServerResponse): AbortSignal {
  const controller = new AbortController()
  response.on('close', () => {
    if (!response.writableFinished) controller.abort()
  })
  return controller.signal
}

await renderer.compile({ source, data, signal: abortSignalFor(res) })

(The writableFinished check is about honesty, not lost renders — aborting a signal whose compile already settled is a no-op. Without it, your telemetry counts every successful delivery as a disconnect, and anything else observing the same signal is misled.)

Aborted while queued, the compile leaves the wait without ever spawning — the semaphore slot goes straight to the next caller. Aborted while running, the Typst process is killed. Both reject with TypstAbortedError, whose phase field tells you which happened — with the default concurrency: 1, a long queue of cancelled requests costs zero compiles. queued aborts in volume mean your queue is too long; running aborts mean clients give up mid-compile — different problems, one field apart. In tests, FakeRenderer records the signal (lastRender.signal), so a spec can assert the wiring exists at all.

Preflight — check

The templates were validated against one Typst version; the themes name fonts; the { path } libraries point at files. check() verifies all of it at boot, and reports instead of throwing — a health endpoint wants the whole picture, not the first failure:

const diag = await renderer.check({
  version: '0.15.1', // exact — or a predicate: (v) => v.startsWith('0.15')
  fonts: ['Lato', 'JetBrains Mono'],
})
// {
//   ok: false,
//   binary:    { ok: true, bin: '/opt/typst' },
//   version:   { ok: true, evaluated: true, actual: '0.15.1', expected: '0.15.1' },
//   fonts:     { ok: false, evaluated: true, missing: ['JetBrains Mono'], available: [...] },
//   libraries: { ok: true },
// }

When the binary itself is unusable, the sections that depend on it stay present but report evaluated: false — they were asked, but could not be judged. Unevaluated sections carry expectations, never findings: missing and available come back empty, so even a doctor that forgets to check the flag cannot print a font hunt caused by a dead binary. Print the binary error, skip unevaluated sections: one problem, one line.

Two things make this worth a line in your deploy: Typst falls back silently when a font is missing — the PDF just comes out wrong — and check() answers "why did my font not apply" before any PDF exists. And it forces the lazy { path } library reads, so a wrong path fails in the deploy log instead of at the first render in production. Version ranges are deliberately not built in (this package has zero runtime dependencies) — pass a predicate and bring your own semver if you need one.

FakeRenderer implements check() against its canned version/fonts, so a doctor command is testable without the binary.

Observability — onRender

One callback in the constructor, called once per compile, success or failure:

new TypstRenderer({
  bin,
  onRender: ({ template, format, queuedMs, compileMs, bytes, pages, ok, errorCode }) => {
    logger.info({ template, queuedMs, compileMs, bytes }, 'typst render')
  },
})

queuedMs separate from compileMs is the point: the wait inside the semaphore is invisible from outside the library, and under load with concurrency: 1 it is the dominant latency component. queuedMs growing says raise concurrency or move to a queue; compileMs growing says the documents got heavier — two different decisions, two numbers. pages is reported for paged formats only (a PDF's page count is not known for free), and a throw inside the callback is swallowed: the observer never breaks the render.

The feedback channel — eval

The template can describe what it laid out; the caller asks:

const marks = await renderer.eval({ source, data }, 'query(<mark>).map(it => it.value)')

Expressions run before layout. Layout facts — page numbers, positions — must be marked in the document and read back:

#context [#metadata((page: here().page())) <mark>]

This is what replaces guessing page-fit with character counts. (typst query is deprecated in 0.15; this wraps its replacement.)

Also: await renderer.version() — assert the binary matches what your templates were validated against — and await renderer.fonts() — diagnose the font that silently did not apply.

Errors you can act on

try {
  await renderer.compile({ source, data })
} catch (error) {
  if (error instanceof TypstCompileError) {
    error.file // 'main.typ'
    error.line // 12
    error.column // 9
    error.hint // 'try wrapping this in a `context` expression'
    error.raw // full compiler output, always kept
  }
}

| Error | code | When | | ------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | TypstNotAvailableError | E_TYPST_NOT_AVAILABLE | the binary could not be executed | | TypstCompileError | E_TYPST_COMPILE | Typst rejected the document | | TypstTimeoutError | E_TYPST_TIMEOUT | the compile exceeded timeoutMs, killed with SIGKILL | | TypstKilledError | E_TYPST_KILLED | the process died from a signal this package did not send — OOM killer, operator, crash | | TypstAbortedError | E_TYPST_ABORTED | the caller's AbortSignal fired — phase says whether it was still queued, preparing the workdir, or already running | | TypstFileSourceError | E_TYPST_FILE_SOURCE | a { path } in libraries/files could not be read — carries the entry key and the path | | TypstFileThunkError | E_TYPST_FILE_THUNK | a function file source threw or rejected, or the stream it returned failed mid-read — carries the entry key, with your failure in cause | | InvalidFileSourceError | E_TYPST_INVALID_FILE_SOURCE | a files/libraries value was not a file source — most often a bare stream that should have been () => stream | | InvalidWorkdirPathError | E_TYPST_INVALID_WORKDIR_PATH | a files key tried to escape the render directory |

All of them extend TypstError, so "any failure from the Typst layer" is one instanceof. Prefer matching on code where module duplication is possible (a pnpm monorepo resolving two copies of the package breaks instanceof silently; a string literal survives any module graph).

The timeout/killed split matters operationally: a TypstTimeoutError says raise timeoutMs or shrink the document; a TypstKilledError with signal: 'SIGKILL' on a small host usually says the OOM killer visited — raising the timeout would fix nothing.

Structured fields are best-effort — stderr is console output, not an API — but raw never lies, and a parse failure degrades to "no structured fields", never to a masked error.

Testing without the binary — FakeRenderer

Your tests of headers, ETags and authorization should not vanish on machines without a Typst binary — and asserting what reached the template should not require parsing a PDF. typst-report/testing ships a fake that records every input and returns canned bytes:

import { FakeRenderer } from 'typst-report/testing'

const renderer = new FakeRenderer()
// ... exercise the code under test with it injected ...

expect(renderer.renders).toHaveLength(1)
expect(renderer.lastRender.template).toContain('one_pager.typ')
expect(renderer.lastRender.data.titulo).toBe('Composições poliméricas')
expect(renderer.lastRender.files['img/capa.webp']).toEqual(coverBytes) // resolved to bytes

renderer.failure = new TypstCompileError('boom', 'error: boom') // error paths, real classes

The view model your service hands to the template — the contract this whole package revolves around — becomes a plain assertion. The kit works against the fake too: report.toPdf(rows, { renderer: fake }) records the generated document data.

One requirement: the renderer must arrive by injection, and the receiving code must be typed against the Renderer interface, not the TypstRenderer class — the class has private fields, which makes its type nominal, and a service typed against it rejects the fake at compile time:

import type { Renderer } from 'typst-report'

export class OnePagerService {
  constructor(private renderer: Renderer) {} // ← interface, not class
}

Wiring it into an application

If your framework has a DI container, register the Renderer interface there and you are done. Without one, this module is the whole seam — one instance per process, swappable in tests:

// app/typst.ts
import { TypstRenderer, type Renderer } from 'typst-report'

let renderer: Renderer = new TypstRenderer({ bin: process.env.TYPST_BIN! })

export const useRenderer = (): Renderer => renderer

/** Tests only. Returns the undo. */
export const swapRenderer = (next: Renderer): (() => void) => {
  const previous = renderer
  renderer = next
  return () => void (renderer = previous)
}
// some.spec.ts
const fake = new FakeRenderer()
const restore = swapRenderer(fake)
// ... exercise routes, assert fake.renders ...
restore()

This stays in your application on purpose: a swappable global is framework idiom (Adonis' mail.fake(), for instance), and where it lives decides who owns the mutable state. The library's contribution is that both sides of the swap satisfy one interface.

Performance

Measured by examples/13-benchmark.ts (Typst 0.15.1, Node 24, modest Linux x64 — rerun it on your hardware, every number here is reproducible):

cold: 100 rows → pdf                  ~100 ms
warm: 100 rows → pdf                   ~90 ms
warm: 1 000 rows → pdf                ~580 ms
warm: 5 000 rows → pdf              ~2 700 ms

10 concurrent 100-row requests
  concurrency 1 (default):  total ~940 ms · last caller ~860 ms
  concurrency 4:            total ~410 ms · last caller ~330 ms

Compiles are gated per process — Typst has no internal resource limit, so on a modest host the risk is concurrency, not unit cost. The default gate of 1 is deliberate backpressure (the posture BullMQ workers and puppeteer-cluster default to); raise concurrency on hosts with spare cores and memory, since each concurrent compile is a full process with its own peak. Interactive requests do fine up to ~1 000 rows; beyond that, use a job queue. Caching is the application's business — the engine's contribution is determinism, which makes any cache key you build actually work.

Production patterns

Serving PDFs over HTTP from modest hosts:

  • The gate multiplies under a clustered runtime. concurrency caps a process. Under pm2/cluster with N workers, the machine-wide cap is N × concurrency — four workers at the default of 1 means four simultaneous Typst processes, each with its own memory peak. Size the host for the product, not the option.
  • Know your synchronous ceiling. A ~40 ms one-pager is fine inline. A ~300 ms book is at the edge: one compile occupies the (single) gate slot while queued requests wait. Past that, move renders to a job queue (BullMQ or anything like it), store the artifact, and serve bytes — determinism means the cache key is just a hash of the inputs.
  • Fail at deploy, not at the first request. renderer.check({ version, fonts }) at boot verifies the binary, the pinned version, the fonts your themes name, and forces the lazy { path } library reads — a wrong path surfaces in the deploy log instead of a user-facing 500.
  • Serve with ETags for free. Same input, same bytes — hash the response once and 304 repeat readers. This falls out of --creation-timestamp 0; it is why the default is not configurable-by-accident.

Examples

Every example runs directly (TYPST_BIN=/path/to/typst node examples/NN-*.ts) and writes into examples/out/:

| # | File | Shows | | --- | ------------------------ | -------------------------------------------- | | 01 | 01-quick-start.ts | rows → PDF in ten lines | | 02 | 02-custom-template.ts | custom template, thumbnail, eval feedback | | 03 | 03-json-record.ts | fill a certificate from a JSON payload | | 04 | 04-csv-input.ts | CSV in, grouped report out | | 05 | 05-formatters.ts | same definition, two locales | | 06 | 06-themes.ts | same report, two brands | | 07 | 07-formula-png.ts | math → PNG; parsing formulas from data | | 08 | 08-invoice.ts | master–detail in custom mode | | 09 | 09-watermark.ts | the watermark option | | 10 | 10-template-file.ts | template as a file owned by a designer | | 11 | 11-book-custom.ts | a book built by hand in custom mode | | 12 | 12-math-exam.ts | exam + answer key from one question bank | | 13 | 13-benchmark.ts | the numbers above | | 14 | 14-charts.ts | bar, line, donut | | 15 | 15-book.ts | the same book as 11, as one definition | | 16 | 16-crosstab.ts | pivot with margins → PDF + raw matrix | | 17 | 17-custom-libraries.ts | your libraries; Universe with a pinned cache |

How it works, and why

One ephemeral directory per render. mkdtemp (0700), passed as --root: the template reaches nothing else on the filesystem. Cleanup is a single rm, in a finally. Universe imports are the documented exception — see above.

Data travels as a file, never argv. Linux caps a single argument at 128 KiB (MAX_ARG_STRLEN); any real dataset exceeds it.

Output is read from files, never stdout. Binary PDF on stdout invites encoding corruption and maxBuffer ceilings.

Compiles are serialised. Per process — under a clustered runtime each worker has its own queue, so it caps a process, not a machine.

A subprocess, not an in-process binding. Bindings save tens of milliseconds and cost fault isolation: a runaway compile must be killable from outside, which is impossible once it runs inside your Node process.

Deterministic by default. System fonts ignored, creation timestamp fixed: same input, same bytes — which is what makes ETag caching and visual regression testing possible.

Development

npm install
npm test                               # unit specs
TYPST_BIN=/path/to/typst npm test      # plus end-to-end specs
npm run lint && npm run typecheck
npm run build && npm run check:exports     # publint + are-the-types-wrong

Developing the repo needs npm ≥ 11 (Node 24's default; on Node 22 run npm install -g npm first) — npm 10 crashes on the dev-dependency tree. Consuming the package has no such requirement: zero runtime dependencies, installs on stock Node 22.

CI runs the same gates on every push (ubuntu + windows, Node 22 and 24; ubuntu with a pinned, checksum-verified Typst for the end-to-end specs). Releases are published from v* tags via npm Trusted Publishing, with provenance.

End-to-end specs skip when TYPST_BIN is unset.

Not included

  • Binary installation. The binary is a parameter by design, and never a postinstall (pnpm ≥ 10 blocks lifecycle scripts) — provision and pin it yourself, the same way you pin the package.
  • Browser rendering. See typst.ts — a different problem.
  • A visual designer or XML templates. The template language is Typst.
  • PII policy, brand assets, domain models. Those belong to applications.

License

MIT