Check request limits before rendering¶
GET /v1/constraints tells a client what one Prelum deployment accepts in a render request. Use it
to check file names and request sizes before uploading a project, so a user receives immediate
feedback instead of waiting for POST /v1/render to reject the request.
You can skip this endpoint in a simple integration: POST /v1/render always validates the complete
request and returns a structured error. Constraints are useful when building an upload form, giving
early feedback for large projects or implementing a reusable client library.
Fetch the constraints once when the client starts and refresh them when its Prelum deployment or configuration changes. There is no need to call the endpoint before every render.
Make a request¶
The endpoint has no request body. Send the same API token that you use for rendering:
curl --silent --show-error http://localhost:9870/v1/constraints \
-H "X-Prelum-Api-Token: dev-only-insecure-token" \
--output prelum-constraints.json
A successful response is JSON. It contains two kinds of information:
- key rules describe valid names in the
filesobject and are the same for every deployment running that version of Prelum; - limits describe sizes and counts accepted by this particular deployment and may differ between environments.
Use jq to inspect the fields that most clients need first:
With the default development configuration, the result is:
{
"rules_version": 5,
"max_keys": 1024,
"limits": {
"effective_max_files": 64,
"max_inline_files": 64,
"max_inline_file_bytes": 1048576,
"max_template_source_bytes": 524288,
"max_string_bytes": 1048576,
"max_request_body_bytes": 20971520,
"max_output_bytes": 52428800,
"max_output_files": 64
}
}
The endpoint returns 403 Forbidden if the token is missing or incorrect.
Use the response¶
For a client that sends source, files and data to POST /v1/render, the usual checks are:
- encode
sourceas UTF-8 and keep it withinlimits.max_template_source_bytes; - keep the number of
filesentries withinlimits.effective_max_files; - validate every
fileskey using the rules described below; - keep each decoded file within
limits.max_inline_file_bytes; - keep each string in
datawithinlimits.max_string_bytes; - keep the complete JSON request within
limits.max_request_body_bytes.
For example, these keys can be used directly in a render request:
| Key | Accepted | Reason |
|---|---|---|
assets/logo.png |
yes | Normalised relative path with safe characters |
lib/report.typ |
yes | Nested relative path |
main.typ |
yes | No filename is reserved |
../secret.txt |
no | Leaves the project directory |
/etc/passwd |
no | Absolute path |
assets//logo.png |
no | Empty path segment |
An accepted key becomes the path that Typst uses inside the temporary project. This request makes
the image available to the source as assets/logo.png:
{
"source": "#image(\"assets/logo.png\")",
"files": {
"assets/logo.png": {
"encoding": "base64",
"content": "<base64-encoded PNG>"
}
},
"output": {
"format": "pdf"
}
}
The endpoint also returns conformance_vectors: ready-made accepted and rejected key sets. SDK
authors can run these cases against their local validator to prove that it agrees with Prelum.
Code examples¶
Most applications should not copy Prelum's complete validator into client code. Use
/v1/constraints for a few inexpensive checks that improve feedback, then send the complete
request to /v1/render. The render endpoint validates the request authoritatively and returns a
structured problem response if anything is wrong.
Python 3.14¶
import json
import os
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
base_url = "http://localhost:9870"
headers = {"X-Prelum-Api-Token": os.environ["PRELUM_API_TOKEN"]}
# Fetch the deployment's limits once when the client starts.
with urlopen(Request(f"{base_url}/v1/constraints", headers=headers), timeout=10) as response:
constraints = json.load(response)
render_request = {
"source": '#include "greeting.typ"\n\n#data.name',
"files": {
"greeting.typ": {"encoding": "text", "content": "Hello from Prelum!"},
},
"data": {"name": "Ada"},
"output": {"format": "pdf"},
}
limits = constraints["limits"]
# Give immediate feedback for the most useful whole-request limits.
if len(render_request["source"].encode("utf-8")) > limits["max_template_source_bytes"]:
raise ValueError("The Typst source is too large")
if len(render_request["files"]) > limits["effective_max_files"]:
raise ValueError("The request contains too many files")
body = json.dumps(render_request, separators=(",", ":")).encode("utf-8")
if len(body) > limits["max_request_body_bytes"]:
raise ValueError("The complete request is too large")
try:
request = Request(
f"{base_url}/v1/render",
data=body,
method="POST",
headers={**headers, "Content-Type": "application/json"},
)
with urlopen(request, timeout=30) as response:
Path("result.pdf").write_bytes(response.read())
except HTTPError as error:
problem = json.load(error)
raise ValueError(f"Prelum rejected the request: {problem['detail']}") from error
The important part is that /v1/render receives and validates the complete render_request.
Constraints provide faster local feedback but do not replace that validation.
TypeScript¶
import { writeFile } from "node:fs/promises"
type Constraints = {
limits: {
effective_max_files: number
max_request_body_bytes: number
max_template_source_bytes: number
}
}
const baseUrl = "http://localhost:9870"
const apiToken = process.env.PRELUM_API_TOKEN
if (!apiToken) throw new Error("PRELUM_API_TOKEN is required")
const headers = { "X-Prelum-Api-Token": apiToken }
const constraintsResponse = await fetch(`${baseUrl}/v1/constraints`, { headers })
if (!constraintsResponse.ok) throw new Error("Could not fetch Prelum constraints")
const constraints = (await constraintsResponse.json()) as Constraints
const renderRequest = {
source: '#include "greeting.typ"\n\n#data.name',
files: {
"greeting.typ": { encoding: "text", content: "Hello from Prelum!" },
},
data: { name: "Ada" },
output: { format: "pdf" },
}
const limits = constraints.limits
if (Buffer.byteLength(renderRequest.source, "utf8") > limits.max_template_source_bytes) {
throw new Error("The Typst source is too large")
}
if (Object.keys(renderRequest.files).length > limits.effective_max_files) {
throw new Error("The request contains too many files")
}
const body = JSON.stringify(renderRequest)
if (Buffer.byteLength(body, "utf8") > limits.max_request_body_bytes) {
throw new Error("The complete request is too large")
}
const response = await fetch(`${baseUrl}/v1/render`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body,
})
if (!response.ok) {
const problem = (await response.json()) as { detail: string }
throw new Error(`Prelum rejected the request: ${problem.detail}`)
}
await writeFile("result.pdf", Buffer.from(await response.arrayBuffer()))
This is normally enough for an application integration. SDK authors who need an exact client-side
mirror can implement the per-key and whole-set rules below, then verify their implementation with
conformance_vectors.
Response fields¶
| Field | Meaning |
|---|---|
rules_version |
Changes when the published key rules change |
key_pattern |
A PCRE-form expression covering the per-key rules |
max_keys |
Structural upper bound for the number of keys |
set_rules |
Rules that compare two or more keys |
conformance_vectors |
Accepted and rejected examples for testing a local validator |
limits |
Limits configured for the deployment that answered the request |
The static files-key-rules.json file contains the same key rules for tools
that cannot contact a running service. It does not contain limits, because a static document
cannot describe an individual deployment.
The response also carries output_rules, the matching contract for the output object, under its
own version counter. Choose an output format describes it, and it is published
statically as output-rules.json.
Reference: per-key shape¶
Every per-key rule is published twice: once as data, and once folded into key_pattern for the
engines that can compile it. The data is the contract. Split a key on / and require of every
segment that it is:
- at least
min_segment_lengthand at mostmax_segment_lengthcharacters — the minimum is what rejectslib//label.typ,lib/and/lib/x.typ, each of which splits into an empty segment; - built only from characters matching
segment_character_class; - not made only of dots, while
segments_may_not_be_only_dotsis true —.,..and...are all rejected.
Then one rule about the key as a whole: it must be between min_key_length and max_key_length
characters once joined. That is the one rule a per-segment check cannot see — what reaches the
filesystem is the absolute project root plus the key, and max_key_length is what remains after the
room reserved for that root.
That is the whole per-key contract, in any language, with no regular expression involved. It is also exactly what Prelum itself does — the service validates structurally and publishes the expression, not the other way round.
No filename is reserved. main.typ is an ordinary key, at the root as much as anywhere else:
Prelum writes your source to a name that no valid key can spell, so there is nothing for a key to
collide with.
The expression¶
key_pattern folds the three segment rules into one anchored expression. Restricting the character
set makes separate absolute-path and normalisation checks unnecessary: a leading /, //, ./, a
trailing / and .. all fail it.
It begins with \A and ends with \z, not ^ and $. In PCRE, Java and Python $ also matches
before a trailing newline, and in Ruby both ^ and $ are line anchors whatever flags you pass, so
a mirror anchored with either accepts lib/label.typ\n or bad\nlib/label.typ while Prelum rejects
both. A conformance vector covers each end.
key_pattern_flavour is pcre. Java, .NET, PHP, Ruby and Python 3.14 or later take it as written.
Other common runtimes need an edit or a rewrite:
- Python 3.13 and earlier.
\zwas only added to theremodule in 3.14; before that it raisesre.error: bad escape \z. Replace every\zwith\Z, which in Python — unlike in PCRE and Java — already means the absolute end of the string. Do not substitute$. - JavaScript. There is neither
\Anor\z. Without themflag,^is an absolute start anchor, but$may still match before a final line break. Replace the leading\Awith^and every\zwith(?![\s\S]), a negative lookahead that can only succeed at the absolute end. - Go and Rust.
regexpandregexare RE2, which has no lookahead, so(?!...)does not compile and the expression is unusable at all. Apply the four rules above as code instead; nothing is lost, because each one is published as data.
The \z occurrences are the two inside the dot-segment lookaheads and the one inside the length
lookahead as well as the final anchor, so replace all of them, not only the last.
Whichever route you take, the conformance vectors are what tell you it is equivalent.
Whole-set rules¶
Three rules need the whole mapping and are published as set_rules rather than as data:
too_many_keys— at mostmax_keysentries, checked first because the other two are O(keys × depth).case_collision— no two keys whose case-folded forms are equal.ancestor_collision— no key whose case-folded form equals a case-folded POSIX ancestor of another key. Fold case on both sides:libandLIB/x.typcollide.
How many files¶
max_keys (1024) is structural and never changes with configuration. limits.max_inline_files is
the deployment's own cap and is often lower — 64 by default. The effective cap is the smaller of the
two, reported as limits.effective_max_files. Mirroring only one of them gets the contract wrong in
one direction or the other.
Conformance vectors¶
conformance_vectors is executable, not illustrative. Each entry gives a list of keys, whether
Prelum accepts them, the problem code if not, and the set_rules id where one applies. Prelum runs
every vector against its own validators in CI, so a vector cannot describe behaviour the service does
not have.
Run them against your mirror in your own CI. That, rather than reading the rules, is what keeps a mirror from drifting.
rules_version rises whenever any published rule changes. Pin it, and treat a change as a signal to
re-run the vectors.
The document itself may gain fields within the same API version, so parse it leniently: ignore members you do not recognise rather than rejecting the response. A strict validator generated from the schema would otherwise fail on an ordinary release. See Fields may be added for the rule and what it excludes.