A serverless JavaScript runtime for FaaS deployments (AWS Lambda, Azure Functions, Cloudflare Workers), powered by Zig and zigts.
- Installation
- Quick Start
- Command Line Reference
- Handler API
- Request Object
- Response Object
- Routing Patterns
- Working with JSON
- Error Handling
- Virtual Modules
- JavaScript Subset Reference
- TypeScript Support
- JSX and TSX Handlers
- Complete Examples
- Performance Tuning
- Compile-Time Verification
- Contract Manifest
- OpenAPI Manifest
- TypeScript SDK
- Runtime Sandboxing
- Declarative Handler Testing
- Route Forge with zigttp expert
- Author-Declared Specs
- Troubleshooting
Pre-built binaries for macOS and Linux (x86_64, aarch64):
curl -fsSL https://raw.githubusercontent.com/srdjan/zigttp/main/install.sh | sh
zigttp --helpThis installs the zigttp command, the only binary you need to follow this
guide.
Building from source needs Zig 0.16.0 (download from ziglang.org; newer compiler releases are best-effort until revalidated):
# Clone the repository
git clone https://github.com/srdjan/zigttp
cd zigttp
# Build release version (optimized for deployment)
zig build -Doptimize=ReleaseFast
# Or debug version
zig build
# Verify installation
./zig-out/bin/zigttp --helpThe resulting release binary is ~4.8MB, has zero runtime dependencies, and can be deployed directly to FaaS platforms or container environments.
The v1 user flow is init -> dev -> edit -> test -> deploy. Each command
auto-detects the project from zigttp.json, so most steps take no arguments.
Local deploy is the default; --local is accepted when you want the target to
be explicit.
zigttp init my-app && cd my-appThis creates src/handler.ts, tests/handler.test.jsonl, public/, zigttp.json, a starter README.md, and a .gitignore. The HTMX template uses src/handler.tsx.
zigttp devEdit src/handler.ts in your editor; HTMX projects use src/handler.tsx. The
terminal proof card re-verifies on save and shows the verdict, proven surface,
proof deltas, counterexamples, and Why: rows for attributed property
demotions. If a save fails, the reload banner names the failing analyzer stage
and keeps the previous handler serving. Press Tab to rotate the proof card's
left pane through three lenses: Properties (the default [+]/[-] pills),
Trade (each proof paired with the substrate restrictions that earned it), and
Handover (a copy-pasteable AI proof certificate).
For a browser mirror of the same live state, run zigttp studio or pass
--studio to zigttp dev. Studio is optional; the terminal loop is the default
first-run experience.
zigttp testtest runs the analyzer first, then runs the project fixture at
tests/handler.test.jsonl. Pass a path to run a different fixture.
zigttp auth claude # one-time setup: paste an Anthropic API key
zigttp expertexpert opens the interactive compiler-in-the-loop agent. It runs the same
analyzers the compiler uses, so it can explain a diagnostic, verify an edit,
and propose a fix against your handler as you work. auth claude stores the
key at ~/.zigttp/providers.json (mode 0600) and the runtime auto-injects it
into ANTHROPIC_API_KEY on launch; a shell-exported value also works and
wins over the stored one.
zigttp deploy
./.zigttp/deploy/my-appdeploy verifies the handler, emits the self-contained binary at
.zigttp/deploy/<project-name>, and appends a kind=deploy row to
.zigttp/proofs.jsonl. No credentials, no Docker, no network access. zigttp deploy --local is the explicit spelling for the same target. See
Proof ledger and badge for the receipt flow and
Production Deployment for container and FaaS targets.
Attestation is default-on. The build signs the contract and bytecode hashes into a JWS embedded in the binary, using the persistent identity at ~/.zigttp/attest/keypair.bin. The running server emits Zigttp-Proofs and Zigttp-Attest response headers on every request and serves GET /.well-known/zigttp-attest as cacheable JSON. zigttp verify <url> validates the signature from any third-party machine. Pass --no-attest to skip signing for a specific build.
After every successful zigttp deploy, the CLI prints a proof review card: the
contract sha, proof level, proven properties, the route/env/egress/cache/
capability surface, and a verdict against the previous deploy (safe,
safe_with_additions, breaking - the same words zigttp dev --watch --prove
uses). The row is appended to .zigttp/proofs.jsonl so the timeline survives
across deploys:
zigttp proofs list # recent deploys and live-reload swaps
zigttp proofs show HEAD # re-render the card from the latest entry
zigttp proofs diff HEAD~1 HEAD # what changed between the last two entries
zigttp proofs export --format md > receipt.md # shareable receipt for a PR
zigttp proofs badge # write ./zigttp-proof.svg + README snippetRefs accept HEAD, HEAD~N, or a contract sha prefix. The ledger persists only
contract-derived identifiers (env var names, egress hosts, cache namespaces,
route patterns, capability names, the contract sha, boolean property flags); no
env values, tokens, or PII enter the file.
For a local, noninteractive walkthrough of the proof model:
zigttp demo --scripted --out proof-demo --export proof-demo/passportOpen proof-demo/passport/index.html to inspect the exported Proof Passport. It
captures the baseline proof, an unsafe edit with a secret-flow witness, the
repair, and the local deploy receipt. To carry the same proof to a pull request,
see the Proof Gate: zigttp proofs gate aggregates a behavioral
verdict across every changed handler and posts it as a sticky PR comment.
For inline experimentation outside a project:
zigttp serve -e "function handler(req) { return Response.text('Hello World!') }"
zigttp serve hello.jszigttp edge runs an in-process edge that loads multiple handler pools behind one listener and routes incoming requests to a named target by host, method, and path prefix:
zigttp edge --config zigttp.edge.jsonUseful for multitenant routing, internal request fan-out, or A/B routing during a migration. Each handler entry is verified at load time, so the edge only listens after every handler in the config is provably safe. See docs/edge.md for the full config reference.
Day-to-day use is five commands:
zigttp init <name> [--template basic|api|htmx]
zigttp dev [options] [handler.ts]
zigttp test [tests.jsonl]
zigttp expert
zigttp deploy [--no-attest]
Run zigttp help --all for the advanced commands - the analyzer commands
(check, prove, mock, link, gen-tests), serve, build, compile,
doctor, studio, edge, proofs, and verify. Each keeps its own
zigttp <command> --help.
Project commands discover zigttp.json from the current directory or any
parent. The default scaffold sets "port": 3000; raw serve without a project
uses the runtime default of 8080.
zigttp init <name> [--template basic|api|htmx]
Creates zigttp.json, src/handler.ts for basic/API projects or
src/handler.tsx for HTMX projects, tests/handler.test.jsonl,
public/, .gitignore, and a starter README. Project names may contain
letters, numbers, -, and _; path-like names are rejected.
zigttp dev [options] [handler.ts]
Runs the analyzer, starts the server, watches the handler and local imports,
and hot-swaps safe changes. By default, dev implies --watch --prove, so
breaking contract changes are blocked and the old handler keeps serving.
Common dev options:
-p, --port <PORT> Port to listen on (project default: 3000)
-h, --host <HOST> Host/IP to bind to
--studio Also serve the optional /_zigttp/studio mirror
--no-prove Watch and reload without proof gating
--no-tour Skip the first-run tour
--quest Replay the proof quest
zigttp test [tests.jsonl]
zigttp expert
zigttp deploy [--no-attest]
test verifies first, then runs declarative request fixtures through the local
runtime. expert opens the interactive compiler-in-the-loop agent.
Bare deploy is local by default and writes .zigttp/deploy/<project-name>
plus a kind=deploy row in .zigttp/proofs.jsonl. The advanced doctor
command (under zigttp help --all) prints a checklist
for the manifest, runtime template, entry file, analyzer result, tests fixture,
optional system/static paths, and runtime-affecting settings. Release owners
can run zigttp doctor --release [--json] [--out FILE] from the repo root to
emit the v0.1.0-beta proof passport: version alignment, release evidence,
gate wiring, public performance-claim drift, unresolved launch blockers, known
reliability gaps, and proof-surface readiness.
OPTIONS:
-p, --port <PORT> Port to listen on
Project default: 3000; raw serve default: 8080
Example: -p 3000
-h, --host <HOST> Host/IP to bind to
Default: 127.0.0.1
Example: -h 0.0.0.0 (all interfaces)
-e, --eval <CODE> Inline JavaScript handler code
Example: -e "function handler(r) { return Response.json({ok:true}) }"
-m, --memory <SIZE> JavaScript runtime memory limit
Default: 0 (no limit)
Supports: k/kb, m/mb, g/gb suffixes
Example: -m 512k, -m 1m
-n, --pool <N> Runtime pool size
Default: auto (2 * cpu count, min 8)
-q, --quiet Disable request logging
Useful for production/benchmarks
--trace <FILE> Record handler I/O traces to JSONL
Useful for replay and verification
--replay <FILE> Replay recorded traces instead of serving traffic
--sqlite <FILE> SQLite database path for zigttp:sql
Required for zigttp:sql query execution
--test <FILE> Run declarative handler tests from JSONL file
Exit code 1 on any test failure
--durable <DIR> Enable durable run/step oplogs in a directory
Required for zigttp:durable
--system <FILE> System registry for zigttp:service
Required for named internal service calls
--watch Watch handler files, hot-swap on change
Requires a file-based handler (not --eval)
--prove With --watch: diff behavioral contracts before swapping
Safe changes apply automatically; breaking changes block
--force-swap With --watch --prove: apply breaking changes anyway
--no-env-check Skip startup env var validation from contract
Useful during development when env vars aren't set
--security-log <FILE> Append security events (policy denies, panics) to FILE
One JSON object per line
--lifecycle <MODE> Runtime pooling policy for self-contained binaries
Values: ephemeral, bounded, reuse
Overrides the contract-derived default
--cors Enable CORS headers on all responses
--static <DIR> Serve static files from directory
--outbound-http Enable native outbound HTTP bridge
--outbound-host <H> Restrict outbound bridge to exact host H
--outbound-timeout-ms Connect timeout for outbound bridge in ms
--outbound-max-response <SIZE>
Maximum outbound response size
--help Show help message
# Custom port
./zig-out/bin/zigttp serve -p 3000 handler.js
# Bind to all interfaces (accessible from network)
./zig-out/bin/zigttp serve -h 0.0.0.0 handler.js
# Increased memory for complex handlers
./zig-out/bin/zigttp serve -m 1m handler.js
# Quiet mode with custom port
./zig-out/bin/zigttp serve -q -p 8000 handler.js
# Record traces for replay
./zig-out/bin/zigttp serve --trace traces.jsonl handler.js
# Durable execution with persisted oplogs
./zig-out/bin/zigttp serve --durable .zigttp-durable handler.js
# Named internal service calls
./zig-out/bin/zigttp serve --system examples/system/system.json examples/system/gateway.ts
# Run declarative handler tests
./zig-out/bin/zigttp serve --test tests.jsonl handler.js
# Inline with all options
./zig-out/bin/zigttp serve -p 3000 -m 512k -e "function handler(r) { return Response.json({ok:true}) }"Every handler file must define a handler function:
function handler(request) {
// Process request
// Return a Response
return Response.text("OK");
}The function receives a request object and must return a Response.
A handler runs once per request, start to finish:
- The server accepts the connection and parses the HTTP request.
- It checks out an isolated JavaScript runtime from the handler pool.
- Your
handlerfunction runs synchronously and must return aResponse. - The server writes the response, frees the request-scoped memory, and returns the runtime to the pool for the next request.
There is no middleware layer and no per-request init or teardown hook - the
handler function is the whole request lifecycle. There is no shared mutable
state between requests except where a virtual module is explicitly designed to
persist it (for example zigttp:cache). If the handler throws or returns an
error the server responds 500 and stays up - see
Limits and Failure Behavior. Runtime internals are covered in
the architecture doc.
The request object contains all information about the incoming HTTP request:
{
method: string, // HTTP method: "GET", "POST", "PUT", "DELETE", etc.
url: string, // Full URL including query string
path: string, // URL path: "/api/users", "/", "/search"
query: object, // Parsed query parameters
headers: object, // HTTP headers as key-value pairs, plus headers.get(name)
body: string | null // Request body (for POST, PUT, PATCH) or null
}function handler(request) {
// Method
console.log(request.method); // "GET", "POST", etc.
// Full URL (including query string)
console.log(request.url); // "/api/users?id=1"
// Path (without query string)
console.log(request.path); // "/api/users"
// Parsed query parameters
console.log(request.query.id); // 1
// Headers
console.log(request.headers.get("Content-Type")); // "application/json"
console.log(request.headers.get("Authorization")); // "Bearer xxx"
// Body (may be null for GET requests)
if (request.body) {
console.log(request.body); // Raw body string
}
console.log(request.text()); // Raw body string or ""
// request.json() reads the same body stream, so call either text() or json()
console.log(request.json()); // Parsed JSON or undefined
return Response.text("OK");
}Current helper semantics:
request.headers.get(name)is case-insensitive and returns the last observed value for that header name, ornull.request.text()returns the raw body string, or""when no body is present.request.json()returns parsed JSON, orundefinedwhen the body is empty or invalid JSON.request.text()andrequest.json()are single-use body readers. After either one runs, further body reads throw. Userequest.bodyif you need the raw string without consuming it.
function handler(request) {
let contentType = request.headers.get("Content-Type") || "";
let auth = request.headers.get("Authorization") || "";
let userAgent = request.headers.get("User-Agent") || "";
let accept = request.headers.get("Accept") || "";
return Response.json({
contentType: contentType,
hasAuth: auth.length > 0,
userAgent: userAgent,
});
}Factory-style HTTP types are also available:
const headers = Headers({ "Content-Type": "application/json" });
headers.append("X-Trace", "abc123");
const request = Request("/items?id=1", {
method: "POST",
headers: headers,
body: "{\"ok\":true}",
});
const response = Response("Created", {
status: 201,
headers: { "X-Reply": "ok" },
});new is not supported by zigttp's parser, so Headers, Request, and Response
are called as plain factory functions.
Create a basic response with optional configuration. Response(body, init?)
creates the same response-shaped object for local composition, while
Response.text/json/html remain the primary direct-return helpers.
// Simple text response
Response.text("Hello World");
// With status code
Response.text("Not Found", { status: 404 });
// With headers
Response.text("OK", {
status: 200,
headers: {
"Content-Type": "text/plain",
"X-Custom-Header": "value",
},
});
// Empty response
Response.text("", { status: 204 });Create a JSON response. Automatically sets Content-Type: application/json.
// Object
Response.json({ message: "Hello", count: 42 });
// Array
Response.json([1, 2, 3, 4, 5]);
// With status
Response.json({ error: "Not found" }, { status: 404 });
// With additional headers
Response.json({ data: "value" }, {
status: 201,
headers: { "X-Request-Id": "12345" },
});Create a plain text response. Sets Content-Type: text/plain.
Response.text("Hello World");
Response.text("Error occurred", { status: 500 });Create an HTML response. Sets Content-Type: text/html.
// Simple JSX component
const page = <h1>Hello World</h1>;
Response.html(renderToString(page));
// Full HTML document
const doc = (
<html>
<head><title>My Page</title></head>
<body>Page content</body>
</html>
);
Response.html(renderToString(doc));Note: For HTML responses, prefer using JSX/TSX with renderToString() rather than string concatenation. See JSX and TSX Handlers for the full reference.
Common status codes:
| Code | Meaning | Usage |
|---|---|---|
| 200 | OK | Successful request |
| 201 | Created | Resource created (POST) |
| 204 | No Content | Success with no body |
| 301 | Moved Permanently | Redirect |
| 302 | Found | Temporary redirect |
| 400 | Bad Request | Invalid input |
| 401 | Unauthorized | Authentication required |
| 403 | Forbidden | Access denied |
| 404 | Not Found | Resource doesn't exist |
| 405 | Method Not Allowed | Wrong HTTP method |
| 500 | Internal Server Error | Server error |
function handler(request) {
let path = request.url;
let method = request.method;
// Exact match
if (path === "/") {
return Response.text("Home page");
}
if (path === "/about") {
return Response.text("About page");
}
if (path === "/api/health") {
return Response.json({ status: "ok" });
}
return Response.text("Not Found", { status: 404 });
}function handler(request) {
let path = request.url;
let method = request.method;
if (path === "/api/users") {
if (method === "GET") {
return getUsers();
}
if (method === "POST") {
return createUser(request);
}
return Response.text("Method Not Allowed", { status: 405 });
}
return Response.text("Not Found", { status: 404 });
}
function getUsers() {
return Response.json([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]);
}
function createUser(request) {
let data = JSON.parse(request.body);
return Response.json({ id: 3, name: data.name }, { status: 201 });
}function handler(request) {
let path = request.url;
// Match /api/users/:id
if (path.indexOf("/api/users/") === 0) {
let id = path.substring("/api/users/".length);
return getUserById(id);
}
// Match /api/posts/:id/comments
if (path.indexOf("/api/posts/") === 0 && path.indexOf("/comments") > 0) {
let parts = path.split("/");
let postId = parts[3]; // ['', 'api', 'posts', 'id', 'comments']
return getComments(postId);
}
return Response.text("Not Found", { status: 404 });
}
function getUserById(id) {
return Response.json({ id: id, name: "User " + id });
}
function getComments(postId) {
return Response.json({ postId: postId, comments: [] });
}function handler(request) {
let path = request.url;
// All /api/* routes
if (path.indexOf("/api/") === 0) {
return handleApi(request);
}
// All /admin/* routes
if (path.indexOf("/admin/") === 0) {
return handleAdmin(request);
}
// Static pages
return handleStatic(request);
}
function handleApi(request) {
let subpath = request.url.substring(4); // Remove '/api'
return Response.json({ api: true, subpath: subpath });
}
function handleAdmin(request) {
// Check auth header
if (!request.headers["Authorization"]) {
return Response.text("Unauthorized", { status: 401 });
}
return Response.json({ admin: true });
}
function handleStatic(request) {
return Response.html(renderToString(<h1>Welcome</h1>));
}// Simple router implementation
function createRouter() {
let routes = [];
return {
get: function (path, handler) {
routes.push({ method: "GET", path: path, handler: handler });
},
post: function (path, handler) {
routes.push({ method: "POST", path: path, handler: handler });
},
put: function (path, handler) {
routes.push({ method: "PUT", path: path, handler: handler });
},
delete: function (path, handler) {
routes.push({ method: "DELETE", path: path, handler: handler });
},
handle: function (request) {
for (let i = 0; i < routes.length; i++) {
let route = routes[i];
if (
route.method === request.method &&
route.path === request.url
) {
return route.handler(request);
}
}
return Response.text("Not Found", { status: 404 });
},
};
}
// Usage
let router = createRouter();
router.get("/", function (req) {
return Response.html(renderToString(<h1>Home</h1>));
});
router.get("/api/users", function (req) {
return Response.json([{ id: 1, name: "Alice" }]);
});
router.post("/api/users", function (req) {
let data = JSON.parse(req.body);
return Response.json(data, { status: 201 });
});
function handler(request) {
return router.handle(request);
}The match expression provides declarative pattern matching for request dispatch. Each arm tests a pattern against the discriminant and returns a single expression.
function handler(req: Request): Response {
return match (req) {
when { method: "GET", path: "/health" }:
Response.json({ ok: true })
when { method: "GET", path: "/version" }:
Response.json({ version: "1.0.0" })
when { method: "POST", path: "/echo" }:
Response.json(req.body)
default:
Response.text("Not Found", { status: 404 })
};
}Match is an expression - it always produces a value. You can assign it to a variable, return it, or pass it as an argument.
Pattern types:
- Object patterns:
when { key: "value" }- tests properties of the discriminant with strict equality - Literal patterns:
when "GET"- tests the discriminant directly - Wildcard:
when _- matches anything (equivalent todefault) - default: catch-all arm
Arms are tested top-to-bottom. The first matching arm's expression is returned. If no arm matches and there is no default, the result is undefined.
The -Dverify flag will warn about match expressions without a default arm, since they may not produce a value. The -Dcontract flag extracts route patterns from match arms with method/path properties.
function handler(request) {
if (request.method !== "POST") {
return Response.text("Method Not Allowed", { status: 405 });
}
// Check content type
let contentType = request.headers["Content-Type"] || "";
if (contentType.indexOf("application/json") === -1) {
return Response.json(
{ error: "Content-Type must be application/json" },
{ status: 400 },
);
}
// Check for body
if (!request.body) {
return Response.json({ error: "Request body is required" }, {
status: 400,
});
}
// Parse JSON
try {
let data = JSON.parse(request.body);
return Response.json({ received: data, ok: true });
} catch (e) {
return Response.json({ error: "Invalid JSON: " + e.message }, {
status: 400,
});
}
}function handler(request) {
// Simple object
let user = {
id: 1,
name: "Alice",
email: "alice@example.com",
active: true,
};
// Nested objects
let response = {
user: user,
metadata: {
timestamp: Date.now(),
version: "1.0",
},
};
// Arrays
let list = {
items: [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
],
total: 2,
};
return Response.json(response);
}function validateJson(body, requiredFields) {
if (!body) {
return { valid: false, error: "Body is required" };
}
try {
let data = JSON.parse(body);
for (let i = 0; i < requiredFields.length; i++) {
let field = requiredFields[i];
if (data[field] === undefined) {
return { valid: false, error: "Missing field: " + field };
}
}
return { valid: true, data: data };
} catch (e) {
return { valid: false, error: "Invalid JSON" };
}
}
function handler(request) {
if (request.url === "/api/users" && request.method === "POST") {
let result = validateJson(request.body, ["name", "email"]);
if (!result.valid) {
return Response.json({ error: result.error }, { status: 400 });
}
// Use result.data
return Response.json({
id: 1,
name: result.data.name,
email: result.data.email,
}, { status: 201 });
}
return Response.text("Not Found", { status: 404 });
}zigts has no try/catch. All errors flow through two patterns: Result types and optional narrowing.
Functions like jwtVerify, decodeJson, decodeForm, decodeQuery,
validateJson, validateObject, and coerceJson return Result-shaped values.
The handler verifier enforces that .ok is checked before .value is accessed.
You can define a generic Result<T> alias for your own annotations:
type Result<T> = { ok: boolean; value: T; error: string };The type checker instantiates this when used - Result<object> becomes { ok: boolean; value: object; error: string }.
import { jwtVerify } from "zigttp:auth";
import { schemaCompile } from "zigttp:validate";
import { decodeJson } from "zigttp:decode";
type Result<T> = { ok: boolean; value: T; error: string };
schemaCompile("user", JSON.stringify({
type: "object",
properties: {
name: { type: "string" }
},
required: ["name"]
}));
function handler(req: Request): Response {
const token = req.headers["authorization"];
const auth: Result<object> = jwtVerify(token, "secret");
if (!auth.ok) return Response.json({ error: auth.error }, { status: 401 });
const body = decodeJson("user", req.body ?? "{}");
if (!body.ok) return Response.json({ errors: body.errors }, { status: 400 });
return Response.json({ user: body.value, claims: auth.value });
}Functions like env(), cacheGet(), parseBearer(), and routerMatch() return
T | undefined. The verifier enforces narrowing before use.
import { env } from "zigttp:env";
function handler(req: Request): Response {
const apiKey = env("API_KEY");
if (!apiKey) return Response.json({ error: "unconfigured" }, { status: 500 });
const dbUrl = env("DATABASE_URL") ?? "postgres://localhost";
return Response.json({ configured: true });
}function errorResponse(status: number, message: string): Response {
return Response.json({ error: true, status, message, timestamp: Date.now() }, { status });
}
function handler(req: Request): Response {
if (!req.headers["authorization"]) {
return errorResponse(401, "Authentication required");
}
if (req.method === "POST" && !req.body) {
return errorResponse(400, "Request body is required");
}
return Response.json({ ok: true });
}zigttp exposes native modules through import { ... } from "zigttp:*".
Use them for environment access, crypto, validation, cache, SQLite, outbound
HTTP, service calls, WebSockets, durable workflows, and structured concurrent
I/O.
The maintained module index is Virtual Modules.
It links to each per-module API page and records the effect classification used
by contract extraction. The source of truth for built-in modules is
packages/zigts/src/builtin_modules.zig, with public specs under
packages/modules/module-specs/.
For examples that combine modules in a handler, see
examples/modules/modules_all.ts,
examples/sql/sql-crud.ts, and
examples/websocket/chat.ts.
zigts implements a restricted JavaScript subset optimized for FaaS workloads.
The restrictions enable compile-time verification, deterministic replay, and
contract extraction. See
restrictions-to-proofs.md for each cut mapped to
the failure class it eliminates and the proof it unlocks (also available as
zigts restrictions [--by proof|class]).
// Variables
let x = 1;
const y = 2;
// Functions
function foo() {}
const bar = (a, b) => a + b; // arrow functions
const add = (a: number): number => a + 1; // with TypeScript annotations
// Objects and Arrays
const obj = { a: 1, b: 2 };
const arr = [1, 2, 3];
const { a, ...rest } = obj; // destructuring with rest
const [first, ...tail] = arr;
// Template literals
const msg = `Hello ${name}, you have ${count} items`;
// Loops
for (const item of array) {} // for-of with break/continue
for (const i of range(10)) {} // range-based iteration
// Operators
const piped = value |> transform |> format; // pipe operator
score += 10; // compound assignment (+=, -=, *=, /=, etc.)
// Array HOFs
const evens = items.filter((n) => n % 2 === 0);
const doubled = items.map((n) => n * 2);
const sum = items.reduce((acc, n) => acc + n, 0);
// Object methods
const keys = Object.keys(obj);
const vals = Object.values(obj);
const entries = Object.entries(obj);
// Optional chaining and nullish coalescing
const name = user?.profile?.name ?? "Anonymous";
// Pattern matching
const result = match (req) {
when { method: "GET", path: "/health" }: Response.json({ ok: true })
default: Response.text("Not Found", { status: 404 })
};
// Built-in objects
JSON.parse(str); JSON.stringify(obj);
Math.floor(x); Math.random();
Date.now(); // only Date.now(), no other Date methods
console.log(value);All unsupported features produce helpful error messages with alternatives:
class- use plain objects and functionsvar- useletorconstwhile,do...while- usefor (const x of range(n))- C-style
for (;;)- usefor (const i of range(n)) for...in- usefor (const k of Object.keys(obj))try/catch/throw- use Result types (check.ok)async/await/Promise- usefetchSync(),parallel(),race()null- useundefined==,!=- use===,!==++,--- usex = x + 1this,new- use explicit params and factory functionsdelete- useconst { key, ...rest } = obj- Regular expressions - use string methods
anytype (TS) - use specific typesastype assertions (TS) - use control flow narrowing
See feature-detection.md for the full 54-feature detection matrix.
zigts always runs in strict mode. Implicit globals and with are errors.
zigts includes a native TypeScript/TSX stripper that removes type annotations at
load time. Use .ts or .tsx files directly without a separate build step.
- Type aliases and generic type aliases
distinct type- Interfaces
- Variable, parameter, and return annotations
- Function generics
import type/export typeSpec<T>,Proof<T, S>, andEffects<T, S>fromzigttp:types- Type guards with
x is T - Readonly fields
- Template literal types
Type assertions (as, satisfies) and the any type are not supported; use
control-flow narrowing or explicit annotations instead.
The TypeScript stripper performs a single-pass transformation:
- Removes type annotations (
: Type,as Type) - Removes interface and type declarations
- Removes generics (
<T>) and generic type aliases (type Result<T> = ...) - Preserves all runtime code unchanged
- Optionally evaluates
comptime()expressions
Generic type aliases are resolved by the type checker. When you write type Result<T> = { ok: boolean; value: T } and use Result<string> in an annotation, the checker instantiates it to { ok: boolean; value: string } for structural validation.
// handler.ts
interface Request {
method: string;
path: string;
headers: Record<string, string>;
body: string | null;
}
interface User {
id: number;
name: string;
email: string;
}
function handler(request: Request): Response {
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
];
if (request.url === "/api/users") {
return Response.json(users);
}
return Response.json({ error: "Not found" }, { status: 404 });
}After stripping, this becomes valid ES5 JavaScript with all type annotations removed.
Combine TypeScript types with JSX syntax in .tsx files. See
JSX and TSX Handlers for the full reference.
The comptime() function evaluates expressions at load time and replaces them
with literal values. This is useful for:
- Pre-computing constants
- Embedding build metadata
- Generating hash-based ETags
- Parsing JSON configuration
// Arithmetic - computed at load time
const x = comptime(1 + 2 * 3); // -> const x = 7;
const bits = comptime(1 << 10); // -> const bits = 1024;
// String operations
const upper = comptime("hello".toUpperCase()); // -> const upper = "HELLO";
const parts = comptime("a,b,c".split(",")); // -> const parts = ["a","b","c"];
// Math constants and functions
const pi = comptime(Math.PI); // -> const pi = 3.141592653589793;
const max = comptime(Math.max(1, 5, 3)); // -> const max = 5;
const root = comptime(Math.sqrt(2)); // -> const root = 1.4142135623730951;
// Objects and arrays
const config = comptime({ timeout: 30 }); // -> const config = ({timeout:30});
const arr = comptime([1, 2, 3]); // -> const arr = [1,2,3];Generate deterministic hashes for cache keys or ETags:
// FNV-1a hash returns 8-character hex string
const etag = comptime(hash("content-v1")); // -> const etag = "a1b2c3d4";
function handler(request: Request): Response {
return Response.text("Content", {
headers: { "ETag": etag },
});
}Parse JSON at compile time:
const config = comptime(JSON.parse('{"debug":false,"maxItems":100}'));
// -> const config = ({debug:false,maxItems:100});String method chaining works in comptime:
const cleaned = comptime(" Hello World ".trim().toUpperCase());
// -> const cleaned = "HELLO WORLD";
const slug = comptime("My Blog Post".toLowerCase().replace(" ", "-"));
// -> const slug = "my-blog-post";Expressions inside JSX are also evaluated:
const el = (
<div class={comptime("container-" + hash("v1"))}>
{comptime(Math.PI.toFixed(2))}
</div>
);
// -> <div class="container-a1b2c3d4">3.14</div>- Numbers:
42,3.14,-1,0xFF - Strings:
"hello",'world' - Booleans:
true,false - Special:
null,undefined,NaN,Infinity
| Type | Operators |
|---|---|
| Unary | + - ! ~ |
| Arithmetic | + - * / % ** |
| Bitwise | | & ^ << >> >>> |
| Comparison | == != === !== < <= > >= |
| Logical | && || ?? |
| Ternary | cond ? a : b |
| Pipe | a |> f (desugars to f(a)) |
| Compound | += -= *= /= %= **= &= |= ^= <<= >>= >>>= |
Math.PI,Math.E,Math.LN2,Math.LN10Math.LOG2E,Math.LOG10E,Math.SQRT2,Math.SQRT1_2
abs,floor,ceil,round,truncsqrt,cbrt,pow,exp,log,log2,log10sin,cos,tan,asin,acos,atan,atan2min,max,sign,hypotclz32,imul,fround
.lengthtoUpperCase(),toLowerCase()trim(),trimStart(),trimEnd()slice(start, end?),substring(start, end?)includes(search),startsWith(search),endsWith(search)indexOf(search),charAt(index)split(delimiter),repeat(count)replace(search, replacement),replaceAll(search, replacement)padStart(length, padStr?),padEnd(length, padStr?)
.length
parseInt(str, radix?),parseFloat(str)JSON.parse(str)- parses JSON string to comptime valuehash(str)- FNV-1a hash, returns 8-char hex string
Certain operations are not allowed in comptime and will produce errors:
// These will fail at load time:
comptime(foo + 1); // Error: unknown identifier 'foo'
comptime(Date.now()); // Error: Date.now() not allowed (non-deterministic)
comptime(Math.random()); // Error: Math.random() not allowed (non-deterministic)
comptime(() => 1); // Error: function literals not allowed
comptime(x = 1); // Error: assignments not allowedError messages include line and column information for debugging.
- Zero-cost types: Type annotations are stripped with no runtime overhead
- Pre-computed values:
comptime()shifts computation from runtime to load time - Smaller output: Type declarations don't appear in the stripped output
- Single-pass: Stripping happens in one pass, no AST construction
zigttp parses JSX/TSX natively for server-side rendering. JSX mode turns on from
the .jsx/.tsx file extension, so no separate transformer is required. Use
class (not className) for HTML class attributes.
// handler.tsx
function handler(req: Request): Response {
const page = <div class="hello">Hello JSX!</div>;
return Response.html(renderToString(page));
}The runtime is provided by packages/zigts/src/http.zig:
h(tag, props, ...children)- creates virtual DOM nodes. JSX codegen calls it for you; you rarely call it directly.<div class="foo">Hello</div>compiles toh('div', {class: 'foo'}, 'Hello').renderToString(node)- renders a node to an HTML string forResponse.html.Fragment(<>...</>) - groups elements without a wrapper element.
| Feature | Example | Output |
|---|---|---|
| Elements | <div>text</div> |
<div>text</div> |
| Attributes | <div class="foo"> |
<div class="foo"> |
| Expressions | <div>{value}</div> |
<div>...</div> |
| Components | <Card title="x"/> |
calls the Card function |
| Fragments | <>a</> |
a (no wrapper) |
| Self-closing | <br/> |
<br /> |
| Boolean attrs | <input disabled/> |
<input disabled /> |
Components are functions that return JSX and receive a single props object. The
special children prop holds nested content:
function Layout(props) {
return (
<html>
<head><title>{props.title}</title></head>
<body>
<h1>{props.title}</h1>
{props.children}
</body>
</html>
);
}
function handler(request) {
const page = (
<Layout title="My App">
<p>This is the page content</p>
</Layout>
);
return Response.html(renderToString(page));
}Use JavaScript expressions inside JSX. Both arrow and function callbacks work
in .map:
function ProductList(props) {
return (
<div class="product-list">
{props.products.map((p) => (
<div class="product" key={p.id}>
<h3>{p.name}</h3>
<p>Price: ${p.price}</p>
</div>
))}
</div>
);
}Use ternaries or logical AND for conditional rendering (remember: undefined,
not null, is the absent sentinel):
function UserProfile(props) {
const user = props.user;
return (
<div class="profile">
<h2>{user.name}</h2>
{user.isPremium ? <span class="badge">Premium</span> : <span class="badge">Free</span>}
{user.email && <p>Email: {user.email}</p>}
</div>
);
}// Dynamic class
<div class={isActive ? "active" : "inactive"}>Status</div>
// Boolean attributes
<input type="checkbox" disabled />
<button disabled={isLoading}>Submit</button>
// Inline style as a string
<div style="color: red; font-size: 16px;">Styled text</div>Combine TypeScript types with JSX. Annotations strip at load time and are checked by the compiler pipeline:
interface PageProps {
title: string;
children: any;
}
function Layout(props: PageProps) {
return (
<html>
<head><title>{props.title}</title></head>
<body>{props.children}</body>
</html>
);
}
function handler(request: Request): Response {
return Response.html(renderToString(<Layout title="Users"><h1>Welcome</h1></Layout>));
}The same JS subset applies inside JSX: arrow functions, template literals,
destructuring, spread, for...of, optional chaining, and nullish coalescing are
supported; classes, var, while, and this are rejected at parse time. For
complete working files, see examples/jsx/ and
examples/handler/handler-full.tsx.
// In-memory data store
let users = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
];
let nextId = 3;
function handler(request) {
let path = request.url;
let method = request.method;
// GET /api/users - List all users
if (path === "/api/users" && method === "GET") {
return Response.json(users);
}
// POST /api/users - Create user
if (path === "/api/users" && method === "POST") {
try {
let data = JSON.parse(request.body);
if (!data.name || !data.email) {
return Response.json({ error: "name and email required" }, {
status: 400,
});
}
let user = { id: nextId++, name: data.name, email: data.email };
users.push(user);
return Response.json(user, { status: 201 });
} catch (e) {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
}
// GET /api/users/:id - Get single user
if (path.indexOf("/api/users/") === 0 && method === "GET") {
let id = parseInt(path.substring("/api/users/".length), 10);
let user = findUser(id);
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
}
return Response.json(user);
}
// PUT /api/users/:id - Update user
if (path.indexOf("/api/users/") === 0 && method === "PUT") {
let id = parseInt(path.substring("/api/users/".length), 10);
let user = findUser(id);
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
}
try {
let data = JSON.parse(request.body);
if (data.name) user.name = data.name;
if (data.email) user.email = data.email;
return Response.json(user);
} catch (e) {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
}
// DELETE /api/users/:id - Delete user
if (path.indexOf("/api/users/") === 0 && method === "DELETE") {
let id = parseInt(path.substring("/api/users/".length), 10);
let index = findUserIndex(id);
if (index === -1) {
return Response.json({ error: "User not found" }, { status: 404 });
}
users.splice(index, 1);
return Response.text("", { status: 204 });
}
return Response.json({ error: "Not Found" }, { status: 404 });
}
function findUser(id) {
for (let i = 0; i < users.length; i++) {
if (users[i].id === id) return users[i];
}
return null;
}
function findUserIndex(id) {
for (let i = 0; i < users.length; i++) {
if (users[i].id === id) return i;
}
return -1;
}function Layout(props) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{props.title} | My Site</title>
<style>{`
body {
font-family: -apple-system, "San Francisco", "Roboto", "Segoe UI", sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
nav { margin-bottom: 20px; }
nav a { margin-right: 15px; }
.success { color: green; }
.error { color: red; }
`}</style>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
<main>{props.children}</main>
</body>
</html>
);
}
function HomePage() {
return (
<Layout title="Home">
<h1>Welcome to My Site</h1>
<p>This is a simple web application powered by zigttp.</p>
<p>Built with Zig and zigts for serverless deployments.</p>
</Layout>
);
}
function AboutPage() {
return (
<Layout title="About">
<h1>About</h1>
<p>zigttp is a serverless JavaScript runtime powered by zigts.</p>
<h2>Features</h2>
<ul>
<li>Instant cold starts</li>
<li>Zero dependencies</li>
<li>ES5 JavaScript with select ES6+ features</li>
</ul>
</Layout>
);
}
function ContactPage() {
return (
<Layout title="Contact">
<h1>Contact Us</h1>
<form method="POST" action="/contact">
<p>
<label>Name:<br /><input type="text" name="name" required /></label>
</p>
<p>
<label>Email:<br /><input type="email" name="email" required /></label>
</p>
<p>
<label>Message:<br /><textarea name="message" rows="5" required></textarea></label>
</p>
<p><button type="submit">Send Message</button></p>
</form>
</Layout>
);
}
function ThankYouPage() {
return (
<Layout title="Thank You">
<h1>Thank You!</h1>
<p class="success">Your message has been received.</p>
<p><a href="/">Return to Home</a></p>
</Layout>
);
}
function NotFoundPage() {
return (
<Layout title="Not Found">
<h1>404 - Page Not Found</h1>
<p>The page you requested does not exist.</p>
<p><a href="/">Return to Home</a></p>
</Layout>
);
}
function handler(request) {
const path = request.url;
const method = request.method;
if (path === "/" && method === "GET") {
return Response.html(renderToString(<HomePage />));
}
if (path === "/about" && method === "GET") {
return Response.html(renderToString(<AboutPage />));
}
if (path === "/contact" && method === "GET") {
return Response.html(renderToString(<ContactPage />));
}
if (path === "/contact" && method === "POST") {
// Log form submission (simplified)
console.log("Contact form submitted:", request.body);
return Response.html(renderToString(<ThankYouPage />));
}
return Response.html(renderToString(<NotFoundPage />), { status: 404 });
}let startTime = Date.now();
let requestCount = 0;
function handler(request) {
requestCount++;
if (request.url === "/health") {
return Response.json({
status: "healthy",
timestamp: Date.now(),
});
}
if (request.url === "/metrics") {
let uptime = Date.now() - startTime;
return Response.json({
uptime_ms: uptime,
uptime_seconds: Math.floor(uptime / 1000),
total_requests: requestCount,
runtime: "zigts",
});
}
if (request.url === "/ready") {
// Readiness check - could include dependency checks
return Response.json({ ready: true });
}
return Response.json({
message: "Hello",
request_number: requestCount,
});
}zigts outperforms QuickJS in our historical benchmark runs (QuickJS is used only
as an external baseline). See benchmarks/*.json for raw results.
| Operation | zigts | QuickJS | Improvement |
|---|---|---|---|
| stringOps | 16.3M ops/s | 258K ops/s | 63x faster |
| objectCreate | 8.1M ops/s | 1.7M ops/s | 4.8x faster |
| propertyAccess | 13.2M ops/s | 3.4M ops/s | 3.9x faster |
| httpHandler | 1.0M ops/s | 332K ops/s | 3.1x faster |
| functionCalls | 12.4M ops/s | 5.1M ops/s | 2.4x faster |
Run benchmarks: ./zig-out/bin/zigttp-bench
Note: Optional instrumentation (perf), parallel compiler, and JIT modules exist
in packages/zigts/src/ but are not enabled by default.
# Default (256KB) - typical API handlers
./zig-out/bin/zigttp serve handler.js
# Larger (1MB) - complex processing, large JSON
./zig-out/bin/zigttp serve -m 1m handler.js
# Smaller (64KB) - minimal functions
./zig-out/bin/zigttp serve -m 64k handler.jszigttp is optimized for FaaS cold starts:
- Cold-start floor: ~3.5ms in the v0.1.0-beta benchmark pass
- Typical cold start: ~7-15ms, depending on host load
- No JIT warm-up required by default
For request-scoped workloads, zigts uses a hybrid memory model that eliminates GC latency spikes:
- Arena allocator: All request-scoped objects are allocated from a contiguous memory region
- O(1) reset: Between requests, the arena is reset in constant time (no per-object deallocation)
- No GC pauses: Garbage collection is disabled during request handling
- Escape detection: Write barriers prevent arena objects from leaking into persistent storage
This design is ideal for FaaS environments where predictable latency matters more than throughput.
// GOOD: Reuse objects across requests
let responseTemplate = { status: "ok" };
function handler(request) {
responseTemplate.timestamp = Date.now();
return Response.json(responseTemplate);
}
// AVOID: Creating large objects per request
function handler(request) {
// This creates garbage every request
let bigArray = [];
for (let i = 0; i < 10000; i++) {
bigArray.push({ index: i });
}
return Response.json(bigArray);
}CLI options for the standalone server:
zigttp serve -p 8080 -h 127.0.0.1 -n 8 --cors --static ./public handler.jsAdvanced options are available through ServerConfig when embedding Server
directly in Zig:
const config = ServerConfig{
.pool_wait_timeout_ms = 5,
.pool_metrics_every = 1000,
.static_cache_max_bytes = 2 * 1024 * 1024,
.static_cache_max_file_size = 128 * 1024,
};# Quiet mode, bind to all interfaces
./zig-out/bin/zigttp serve -q -h 0.0.0.0 -p 8080 handler.jsHosted deploy is out of core for v0.1.0-beta. zigttp deploy --cloud
and the related account commands (login, logout, review, grants,
revoke-grant) are not available in this release. The supported deploy
path is the self-contained binary zigttp deploy produces.
FROM scratch
COPY zig-out/bin/zigttp /zigttp
COPY handler.js /handler.js
EXPOSE 8080
ENTRYPOINT ["/zigttp", "serve", "-q", "-h", "0.0.0.0", "/handler.js"]# Build for Lambda
zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux
# Package as Lambda deployment
zip function.zip bootstrap handler.js
aws lambda create-function --function-name my-function \
--zip-file fileb://function.zip --runtime provided.al2 \
--handler handler.handler --role arn:aws:iam::...Build with wasm32 target for edge deployment (experimental).
zigttp can statically prove your handler is correct at build time. Add -Dverify to any build command:
zig build -Dhandler=handler.ts -DverifyThe verifier checks six properties:
- Exhaustive returns - every code path through the handler returns a Response
- Result safety - Result values from
jwtVerify,decodeJson,decodeForm,decodeQuery, etc. have.okchecked before.valueis accessed - Unreachable code - statements after unconditional returns are flagged (warning)
- Unused variables - declared variables that are never referenced (warning, suppress with
_prefix) - Non-exhaustive match - match expressions without a default arm (warning)
- Optional safety - optional values from
env(),cacheGet(),parseBearer(), androuterMatch()must be narrowed before use
This is possible because zigttp's JS subset bans most non-trivial control flow (while, try/catch). break and continue are allowed within for-of (forward jumps only). The IR tree is the control flow graph.
Example diagnostics:
verify error: not all code paths return a Response
--> handler.ts:2:17
|
2 | function handler(req) {
| ^
= help: ensure every branch (if/else) ends with a return statement
verify error: optional value used without checking for undefined
--> handler.ts:6:14
|
6 | app: appName,
| ^
= help: check before use: if (val !== undefined) { ... }
or provide a default: val ?? "fallback"
Optional values are narrowed by if (val), if (!val) return, val !== undefined, val ?? default, or reassignment. Optional chaining (val?.prop) is safe.
See verification.md for the full specification, recognized patterns, and test fixtures.
Every precompilation automatically extracts a contract from the handler's IR,
describing what the handler does. Add -Dcontract to also emit the contract as
a contract.json file:
zig build -Dhandler=handler.ts -DcontractThe contract extracts from the handler's IR:
- Which
zigttp:*virtual modules are imported and which functions are used - Literal env var names from
env("NAME")calls - Outbound hosts from
fetchSync("https://...")URL arguments - Named internal service calls from
serviceCall("name", "METHOD /path", init) - System-level payload proof for named internal links, including explicit payload-proof gaps
- Cache namespace strings from
cacheGet/cacheSet/etc. - Registered SQL query names, operations, and touched tables from
sql("name", "...") - Scope names, whether any scope callback remains dynamic, and maximum nested scope depth from
scope("name", fn) - Durable run keys, whether durable keys are dynamic, literal
step()names, timer usage, signal names, and producer keys (targets ofsignal()/signalAt()) - Durable workflow proof data:
workflowId,proofLevel, extracted nodes, and extracted edges forrun()callbacks when zigttp can recover them - API route facts: method/path, path params, query params, header params, JSON request bodies, response variants, and auth requirements when they are statically proven
- Handler effect properties derived from virtual module effect classification (pure, read_only, stateless, retry_safe, deterministic, has_egress).
retry_safeis cleared when scope-managed cleanup is used. - Verification results (when combined with
-Dverify)
Non-literal arguments (e.g., env(someVariable)) set "dynamic": true as an
honest signal that static analysis cannot enumerate all values.
For internal service composition, system.json entries must include name,
path, and baseUrl. serviceCall() uses name; raw fetchSync() linking
continues to match by baseUrl.
# Combine verification and contract
zig build -Dhandler=handler.ts -Dverify -DcontractThe contract is written to src/generated/contract.json alongside the embedded
bytecode.
For a route-focused example:
zig build -Dhandler=examples/routing/api-surface.ts -DcontractRoute entries can include additive API fields like:
{
"method": "POST",
"path": "/profiles/:id",
"pathParams": [
{ "name": "id", "location": "path", "required": true, "schema": { "type": "string" } }
],
"queryParams": [
{ "name": "verbose", "location": "query", "required": false, "schema": { "type": "string" } }
],
"headerParams": [
{ "name": "x-client-id", "location": "header", "required": false, "schema": { "type": "string" } }
],
"requestBodies": [
{ "contentType": "application/json", "schemaRef": "profile.update", "schema": null, "dynamic": false }
],
"responses": [
{ "status": 200, "contentType": "application/json", "schemaRef": null, "schema": { "type": "object" }, "dynamic": false }
],
"queryParamsDynamic": false,
"headerParamsDynamic": false,
"requestBodiesDynamic": false,
"responsesDynamic": false
}The legacy summary fields (responseStatus, responseContentType, responseSchemaRef, responseSchema) are still emitted for compatibility. The *Dynamic flags remain the honest signal that the compiler saw part of the surface but could not enumerate it completely.
Add -Dopenapi to emit a compiler-derived openapi.json alongside the
embedded bytecode:
zig build -Dhandler=handler.ts -DopenapiThe current emitter only includes facts the compiler can prove:
schemaCompile("name", JSON.stringify({...}))schemas become component schemasvalidateJson("name", ...),coerceJson("name", ...), anddecodeJson("name", ...)become JSON request bodiesdecodeForm("name", ...)becomes anapplication/x-www-form-urlencodedrequest bodydecodeQuery("name", ...)contributes typed query parametersparseBearer()/jwtVerify()enable bearer auth metadatarouterMatch()route tables with literal"METHOD /path"keys become OpenAPI paths- literal request access becomes path, query, and header parameters
- proven response variants become OpenAPI
responses
Dynamic schemas or routes are preserved as x-zigttp-* hints instead of guessed
OpenAPI operations. The manifest is written to src/generated/openapi.json.
zig build -Dhandler=examples/routing/api-surface.ts -DopenapiWhen those facts are proven, the generated manifest includes:
POST /profiles/{id}- path/query/header parameters
requestBody.content["application/json"]- response entries under
responses x-zigttp-*flags if any part of the route stays dynamic
Add -Dsdk=ts to emit a dependency-free TypeScript client beside the embedded
handler:
zig build -Dhandler=examples/routing/api-surface.ts -Dsdk=tsThe generated file is written to src/generated/client.ts.
The standalone compiler CLI accepts the matching flag:
zigts compile --sdk ts examples/routing/api-surface.ts /tmp/embedded_handler.zigTyped helpers are generated only for routes the compiler can prove end to end:
- non-dynamic path/query params
- zero or one proven JSON or form request body
- one proven JSON response shape
Routes that do not meet those constraints are still accessible through
requestRaw() and are listed in skippedOperations.
Generated method shape:
method({ params?, query?, body?, headers?, signal? })Example consumer for a fully proven route:
import { createClient } from "./src/generated/client";
const api = createClient({
baseUrl: "https://api.example.com",
});
const result = await api.postProfilesId({
params: { id: "user_123" },
query: { verbose: true },
body: { displayName: "Ada" },
headers: { "x-client-id": "cli-42" },
});
console.log(result.status);
console.log(result.data.displayName);The generated client deliberately prefers omission over approximation. If the compiler cannot prove a clean typed helper, it records the reason instead of inventing a broad type.
Every virtual module function carries a compile-time effect annotation: read (does
not modify external state), write (modifies external state), or none (compile-time
only, like guard). During precompilation, the contract builder reduces those
calls into an internal effect summary, then derives handler-level properties from
that summary:
This handler-facing effect metadata is separate from module-level
required_capabilities, which record what runtime resources (clock, crypto,
stderr, etc.) a virtual module's Zig implementation actually uses. Built-in
and extension modules route sensitive operations through shared checked
helpers, so a mismatch between the declaration and the code panics
at call time rather than silently misbehaving.
| Property | Meaning |
|---|---|
pure |
No virtual module calls and no fetchSync. Handler is a function of the request only. |
readOnly |
All imported functions are read-classified. No state mutations through virtual modules. |
stateless |
Read-only and no cacheGet. Handler does not depend on mutable external state. |
retrySafe |
Read-only, or writes are confined to durable-managed operations with no proven bare writes, and no scope-managed cleanup is present. Safe for Lambda auto-retry on timeout. |
deterministic |
No Date.now() or Math.random() calls detected. |
hasEgress |
Handler uses fetchSync (conservatively classified as write). |
These properties appear in the build output:
Handler Properties:
PROVEN pure handler is a deterministic function of the request
PROVEN read_only no state mutations via virtual modules
PROVEN stateless independent of mutable state
--- retry_safe disabled when scope-managed cleanup or bare writes are present
--- deterministic no Date.now() or Math.random()
They are also included in contract.json under the "properties" key, in AWS
deployment manifests as zigttp:retrySafe and zigttp:readOnly tags, and in
OpenAPI specs as the x-zigttp-properties extension.
Effect classifications by module:
Read-effect functions: env, sha256, hmacSha256, base64Encode,
base64Decode, routerMatch, parseBearer, jwtVerify, jwtSign,
verifyWebhookSignature, timingSafeEqual, schemaCompile, validateJson,
validateObject, coerceJson, schemaDrop, decodeJson, decodeForm,
decodeQuery, cacheGet, cacheStats, sql, sqlOne, sqlMany.
Write-effect functions: cacheSet, cacheDelete, cacheIncr, sqlExec,
parallel, race, run, step, sleep, sleepUntil, waitSignal,
signal, signalAt.
None-effect: guard (compile-time macro, no runtime execution).
Every precompiled handler is automatically sandboxed based on its contract. No
configuration required. The compiler derives a RuntimePolicy from the contract
and embeds it in the generated code.
The contract records whether each capability section (env, egress, cache, sql) uses only literal string arguments or includes dynamic (computed) access:
- Static access (
dynamic: false): the compiler proved all calls use string literals. The sandbox restricts to exactly those values. Any runtime access to an unlisted value throws aCapabilityPolicyError. - Dynamic access (
dynamic: true): some calls use computed arguments. That section remains unrestricted because the compiler cannot enumerate all possible values.
The build prints a sandbox report:
Sandbox: complete (all access statically proven)
env: restricted to [API_KEY, DATABASE_URL] (2 proven, no dynamic access)
egress: restricted to [api.stripe.com] (1 proven, no dynamic access)
cache: restricted to [sessions] (1 proven, no dynamic access)
sql: restricted to [listTodos, createTodo] (2 proven, no dynamic access)
Or for partial proof:
Sandbox derived from contract:
env: restricted to [API_KEY] (1 proven, no dynamic access)
egress: unrestricted (dynamic access detected)
cache: restricted to [] (none proven, no dynamic access)
sql: restricted to [] (none proven, no dynamic access)
Add -Dpolicy=policy.json to override auto-derived sandboxing with an explicit
least-privilege capability policy:
zig build -Dhandler=handler.ts -Dpolicy=policy.json{
"env": { "allow": ["JWT_SECRET"] },
"egress": { "allow_hosts": ["api.example.com"] },
"cache": { "allow_namespaces": ["sessions"] },
"sql": { "allow_queries": ["listTodos", "createTodo"] }
}Explicit policy rules:
- Omit a section to leave that capability unrestricted.
- If a section is present, only the listed literals are allowed.
- Dynamic access in a restricted category fails the build because zigttp cannot enumerate it soundly.
- Local file imports are aggregated before validation, so helper modules count toward the same policy.
Self-extracting binaries (built with zigttp compile handler.ts -o binary)
embed the contract JSON alongside bytecode and policy. At startup, the runtime
parses this contract and uses it for three things:
-
Env var validation. Proven env vars are checked via
getenv()before the server starts listening. If any are missing, the binary exits immediately with a clear error instead of returning a 500 on the first request that hits that code path. Skip with--no-env-checkduring development. -
Route pre-filtering. When the contract proves the handler only serves specific method+path combinations, requests to other routes are rejected with 404 at the HTTP layer without entering JS execution.
-
Property logging. Proven handler properties (retry_safe, deterministic, injection_safe, etc.) are logged at startup for operator visibility.
-
Response memoization. When the contract proves the handler is
pureordeterministic+read_only, GET/HEAD responses are cached in memory and served without entering JS on subsequent identical requests. Cached responses include anX-Zigttp-Proof-Cache: hitheader. The cache uses FIFO eviction (default 1024 entries, 5-minute TTL, 256KB max body). Requests withCache-Control: no-cacheorno-storebypass the cache. -
Attestation response headers (default-on). The runtime emits
Zigttp-Proofs: <chip list>andZigttp-Attest: <compact JWS>on every response unless the build was compiled with--no-attest. Both strings are built once at startup from the embedded JWS and the proven properties, so per-request cost is onebufPrintper header. The signing key is the persistent identity at~/.zigttp/attest/keypair.bin, generated on first use. -
Well-known attestation endpoint. Attested binaries serve
GET /.well-known/zigttp-attestwith the full JWS, embedded contract, and JWK public key. Content-addressed via ETag,Cache-Control: public, max-age=3600, 304 onIf-None-Match. Third parties can runzigttp verify <url>against any handler route to read the response headers, or fetch the well-known doc directly to inspect the full contract surface offline.
Handlers run via zig build run -- (dev mode) are not sandboxed. Sandboxing
requires precompilation (-Dhandler=...) because contract extraction runs as
part of the compile pipeline.
Handler tests use a JSONL format with four entry types. Because handlers are pure functions of (Request, VirtualModuleResponses), testing requires no mocking frameworks or infrastructure - just declare inputs and expected outputs.
# Runtime mode
./zig-out/bin/zigttp serve --test tests.jsonl handler.ts
# Build-time mode (fails the build on test failure)
zig build -Dhandler=handler.ts -Dtest-file=tests.jsonlEach test is a group of JSONL lines:
{"type":"test","name":"GET /health returns 200"}
{"type":"request","method":"GET","url":"/health","headers":{},"body":null}
{"type":"expect","status":200,"bodyContains":"ok"}
{"type":"test","name":"POST /users validates body"}
{"type":"request","method":"POST","url":"/users","headers":{"content-type":"application/json"},"body":"{\"invalid\":true}"}
{"type":"expect","status":400,"bodyContains":"errors"}
{"type":"test","name":"JWT auth with stubbed verify"}
{"type":"request","method":"GET","url":"/secure","headers":{"authorization":"Bearer test-token"},"body":null}
{"type":"io","seq":0,"module":"auth","fn":"jwtVerify","args":["test-token","secret"],"result":{"ok":true,"value":{"sub":"user-123"}}}
{"type":"expect","status":200,"bodyContains":"user-123"}Entry types:
test- Test case header with a namerequest- HTTP request (method, url, headers, body). Usenullfor absent body (JSON has noundefined)io- Virtual module stub. Theseqfield orders multiple stubs within a test. The handler receives this recorded return value instead of calling the real moduleexpect- Assertions:status(exact match) and/orbodyContains(substring match)
Record handler I/O traces during live traffic, then replay them for regression testing:
# Record traces
./zig-out/bin/zigttp serve --trace traces.jsonl handler.ts
# Replay against a handler (offline verification)
./zig-out/bin/zigttp serve --replay traces.jsonl handler.ts
# Build-time replay (fails on regressions)
zig build -Dhandler=handler.ts -Dreplay=traces.jsonlTracing captures every virtual module call (with args and return values), fetchSync responses, Date.now() timestamps, and Math.random() values. Because virtual modules are the only I/O boundary, handlers become deterministic pure functions of (Request, VirtualModuleResponses). Replay substitutes recorded values for all I/O and compares actual vs expected Response.
zigttp expert can add routes through a compiler-native forge flow. The model
does not write the route directly when this path is used; the forge tool
synthesizes a candidate, runs the compiler analysis in memory, and exposes the
diff for approval.
# Preview only: plan, candidate source, diff, and verification summary
/feature route file=handler.ts method=GET path=/health
# Forge: synthesize, prove, and attempt a verifier repair if needed
/forge route file=handler.ts method=POST path=/todos body=todo status=201/feature never writes files. /forge returns a candidate marked ready only
when it introduces zero new compiler violations. Apply a ready result from the
CLI REPL with the corresponding apply command; the apply step reruns the
compiler veto against the current file and records the accepted change as a
verified_patch in the session ledger.
V1 scope is intentionally narrow: route creation only. It can add router-based
dispatch to a plain handler, extend an existing routes table, optionally wire
schema-backed body validation, and return a JSON response with the requested
status. Forge-synthesized handlers ship with a default Spec<...> set declared
on the dispatcher, so the proof obligation lives in source from day one (see
the next section).
Compiler specs are active proof obligations. When a handler declares no
Spec<...>, every supported v1 spec is active by default. Adding
Spec<...> on the handler return type narrows the active set to exactly the
names in that annotation. The marker rides the same TS generic-alias machinery
as Result<T>, strips at runtime, and is read by the verifier after the
analyzer pipeline runs. An active spec the inferred HandlerProperties cannot
satisfy fails the build with ZTS500.
import type { Spec } from "zigttp:types";
type Guardrails = Spec<
| "idempotent"
| "deterministic"
| "no_secret_leakage"
| "injection_safe"
>;
function handler(req: Request): Response & Guardrails {
return Response.json({ ok: true });
}The alias name (Guardrails) is your own; the type checker follows alias
resolution to find the built-in Spec<...> marker and extracts the
string-literal union as the active spec set. Inline use also works:
function handler(req: Request): Response & Spec<"idempotent">.
Fifteen names are recognized; ten produce cause-only failures with a per-property suggestion, five produce counterexample-rich failures with a falsifying request witness:
- Cause-only:
deterministic,read_only,retry_safe,idempotent,state_isolated,fault_covered,pure,stateless,result_safe,optional_safe. - Counterexample-rich:
no_secret_leakage,no_credential_leakage,input_validated,pii_contained,injection_safe.
- ZTS500 - spec_not_discharged: the corresponding
HandlerPropertiesfield is false. Cause-only specs include aTry:suggestion in the HUD; data-flow specs include a falsifying request body produced by the counterexample solver. - ZTS501 - spec_incompatible_with_import: the spec contradicts an imported
virtual-module function. Today this fires for
Spec<"read_only">against imports ofzigttp:cacheorzigttp:sqlwrites. ZTS500 is suppressed for the same name; resolve the import or drop the spec before the agent enters repair. - ZTS502 - spec_unknown_name: the declared name is not in the v1 set. Correct the typo or pick one of the eleven.
Spec<...> covers the handler; Proof<T, S> covers the helpers it calls.
A helper annotates its return type with Proof<T, "...">, which resolves
to T for type checking while declaring a capsule the compiler discharges
against the helper's own body:
import type { Proof } from "zigttp:types";
function fullName(u: User): Proof<string, "pure" | "total"> {
return `${u.first} ${u.last}`;
}Four capsule properties ship in v1: total (every path returns a value),
pure, read_only, deterministic. A helper that declares a capsule it
cannot satisfy fails with ZTS500; an unknown name fails with ZTS502 - both
carry a function field naming the helper. A handler-reachable helper that
breaks a property the handler's Spec<...> demands, while carrying no
capsule for it, fails with ZTS606. Proven helpers compose: the
handler's property accounts for every helper it transitively calls.
Effects<T, "..."> declares a least-privilege ceiling on a function's
capabilities - the inferred effect row may be no wider than the named
set. It resolves to T for type checking, opt-in like Proof<...>:
import type { Effects } from "zigttp:types";
function loadRegion(): Effects<string, "env"> {
return env("REGION");
}A function reaching a capability outside its ceiling fails with
ZTS503; an unknown capability name fails with ZTS504; a declared
capability the function never reaches is the warning ZTS505. The
vocabulary is the runtime capability set: env, clock, random,
crypto, stderr, runtime_callback, sqlite, filesystem,
network, policy_check, websocket.
Effects<...> on the handler's return type is a budget that bounds
every reachable helper. A capability the handler reaches directly outside
the budget fails with ZTS506; one a reachable helper introduces fails
with ZTS607, attributed to that helper. The budget is recorded in
contract.json under sandbox.declaredBudget. Because the effect marker
is distinct from the proof marker, the two capsules compose:
Proof<Effects<string, "crypto">, "total">.
zigts check --require-export-capsules is an opt-in, warning-only docs
mode: it asks every exported helper to carry an explicit capsule
(ZTS507 for a missing Effects<...>, ZTS508 for a missing
Proof<...>). It is off by default and never changes the exit code.
- Live HUD pane under
zigttp serve --watch --prove: aSpecs (active)block beneath the inferred properties shows[*] spec NAMEwhen discharged and[-] spec NAMEwhen not. - Proof studio at
/_zigttp/studio: aSpecs (active)heading after Properties renders each active spec as a green ✓ or red ✗ pill. A failed pill expands inline to its ZTS500/501/502 code, source line and column, and the snippet that demoted the property. The right pane shows the witness corpus with clickable rows that fetch/_zigttp/studio/witness/<key>.jsonand render the falsifying request, IO stubs, and pinned status without a CLI hop. Generated path tests download with a one-click link. A verdict timeline above the Verdict pane shows the last ten rebuilds with sha and recompile time. - Proof ledger: every
swapanddeployevent recordsdeclaredSpecs: [{name, discharged, diagnosticCode?, diagnosticMessage?, sourceLine?, sourceColumn?, sourceSnippet?}]so historical entries diff without re-running the verifier. Only failed specs carry the diagnostic fields. zigts check --jsonaddsdeclared_specs,spec_diagnostics, andproofCapsulesarrays to the proof envelope.declared_specsis the effective active set: defaults when noSpec<...>exists, or the explicit narrowed set when one does. Capsule discharge failures and ZTS606 appear inspec_diagnosticswith afunctionfield.zigttp expert: thepi_specs_statustool returns the active set and discharge state for a handler. Drivepi_repair_planfrom this tool's output rather than from the--goalCLI flag.
The /specs <handler.ts> slash command is the REPL shortcut that calls
pi_specs_status directly.
"No handler specified"
# Wrong:
./zig-out/bin/zigttp serve
# Right:
./zig-out/bin/zigttp serve handler.js
# or
./zig-out/bin/zigttp serve -e "function handler(r) { return Response.text('OK') }""No 'handler' function defined"
// Wrong: missing handler function
console.log("Hello");
// Right: must define handler
function handler(request) {
return Response.text("Hello");
}"SyntaxError" in handler
Common causes: using banned syntax. Check the error message for the suggestion:
'while' is not supported; use 'for-of' with a finite collection instead
'try' is not supported; use Result types instead
'var' is not supported; use 'let' or 'const' instead
'==' is not supported; use '===' instead
JSON validation
// Use schema-backed Result helpers instead of try-catch (which is banned)
import { schemaCompile } from "zigttp:validate";
import { decodeJson } from "zigttp:decode";
schemaCompile("input", JSON.stringify({ type: "object" }));
function handler(req: Request): Response {
const result = decodeJson("input", req.body ?? "{}");
if (!result.ok) return Response.json({ errors: result.errors }, { status: 400 });
return Response.json(result.value);
}// Console methods for debugging
// console.log(value) - stdout
// console.error(value) - stderr
// console.warn(value) - stderr
// console.info(value) - stdout
// console.debug(value) - stdout
function handler(request) {
console.log("Method:", request.method);
console.log("Path:", request.url);
console.log("Headers:", JSON.stringify(request.headers));
console.debug("Body:", request.body);
return Response.text("OK");
}If you see out-of-memory errors:
- Increase memory limit:
-m 512kor-m 1m - Reduce object creation in hot paths
- Avoid storing large amounts of data in letiables
┌─────────────────────────────────────────────────────────────────┐
│ zigttp Quick Reference │
├─────────────────────────────────────────────────────────────────┤
│ START SERVER │
│ zigttp serve handler.ts │
│ zigttp serve -p 3000 -e "function handler(r) {...}" │
├─────────────────────────────────────────────────────────────────┤
│ REQUEST OBJECT │
│ req.method → "GET", "POST", "PUT", "DELETE" │
│ req.url → "/api/users?page=1" │
│ req.path → "/api/users" │
│ req.headers → { "content-type": "..." } │
│ req.body → "..." or undefined │
├─────────────────────────────────────────────────────────────────┤
│ RESPONSE HELPERS │
│ Response.json({ data }) → application/json │
│ Response.text("string") → text/plain │
│ Response.html("<html>") → text/html │
│ Response.redirect("/path", 301) → redirect │
├─────────────────────────────────────────────────────────────────┤
│ VIRTUAL MODULES │
│ import { env } from "zigttp:env" │
│ import { jwtVerify } from "zigttp:auth" │
│ import { validateJson } from "zigttp:validate" │
│ import { decodeJson } from "zigttp:decode" │
│ import { routerMatch } from "zigttp:router" │
│ import { parallel } from "zigttp:io" │
│ import { guard } from "zigttp:compose" │
├─────────────────────────────────────────────────────────────────┤
│ REMEMBER │
│ - Use let/const, never var │
│ - Arrow functions are supported: (x) => x + 1 │
│ - No try/catch: use Result types (.ok check) │
│ - No null: use undefined │
│ - No while: use for (const i of range(n)) │
│ - Handler must return a Response on every path │
└─────────────────────────────────────────────────────────────────┘