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
- Complete Examples
- Performance Tuning
- Compile-Time Verification
- Contract Manifest
- OpenAPI Manifest
- TypeScript SDK
- Runtime Sandboxing
- Declarative Handler Testing
- Route Forge with zigts expert
- Author-Declared Specs
- Troubleshooting
- 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 binary (~500KB) has zero runtime dependencies and can be deployed directly to FaaS platforms or container environments.
The v1 user flow is init → edit → studio → build → deploy --local. Each command auto-detects the project from zigttp.json, so most steps take no arguments.
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.
zigttp studioThe dashboard lives at http://localhost:3000/_zigttp/studio. Edit src/handler.ts in your editor; studio re-verifies on save and shows the verdict, proven surface (routes, env, egress, capabilities), witnesses, and any spec diagnostics.
zigttp build
./.zigttp/build/my-appzigttp deploy --local
./.zigttp/deploy/my-appdeploy --local does everything build does, plus appends a kind=deploy row to .zigttp/proofs.jsonl. No cloud credentials, no Docker, no network access. See docs/deploy-tutorial.md for the hosted-control-plane preview path.
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.
zigttp serve [OPTIONS] <handler.js>
zigttp serve -e "<inline-code>"
OPTIONS:
-p, --port <PORT> Port to listen on
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.
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 Guide for complete documentation.
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 });
}const errorResponse = (status: number, message: string): Response =>
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.
// 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.
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:
// handler.tsx
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 {
const page = (
<Layout title="My App">
<h1>Welcome</h1>
<p>Path: {request.url}</p>
</Layout>
);
return Response.html(renderToString(page));
}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
// 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:
- Binary initialization: < 1ms
- Handler loading: typically < 5ms
- 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.jszigttp deploy cross-compiles the handler to a Linux musl binary,
packages it as an OCI image, pushes it through the zigttp control plane,
and provisions the service. The control plane mints short-lived registry
credentials per deploy and forwards the image to the upstream provider,
so there is no account to create, no registry to configure, and no API
token to manage on the client. The only external tool invoked is zig,
for cross-compilation.
zigttp deploy
zigttp deploy --region eu-west
zigttp deploy --no-wait
zigttp deploy --confirm # acknowledge drift after a warningFlags (all optional):
--region <name>overrides the deployment region for this run.--confirmacknowledges drift detected in.zigttp/deploy-state.jsonand proceeds with a replace-like update.--wait(default) blocks until the service reports ready.--no-waitreturns immediately after the deploy is accepted.-h/--helpprints usage.
If credentials are missing, the CLI first prompts for a Zigttp access
token directly in the terminal. The intended hosted flow is to create
that token in zigttp-admin, then paste it into the CLI. Submit an
empty token to fall back to browser-based device login. Tokens are
stored at ~/.zigttp/credentials; zigttp logout clears them. You can
also sign in ahead of time with zigttp login or
zigttp login --token-stdin. Set ZIGTTP_CONTROL_PLANE_URL to point
at a self-hosted control plane; the default is
https://api.zigttp.dev.
Auto-detection from the current directory:
- Handler file: first match of
handler.ts,handler.tsx,handler.jsx,handler.js, or the same paths undersrc/. - Service name: the
namefield inpackage.json, then the basename of the git origin remote, then the current directory name. Slugified to lowercase with dashes. - Runtime environment:
KEY=valuepairs from.envin the current directory, one per line. Missing file is fine; a malformed line aborts the deploy with apath:linediagnostic. - Region:
--regionif given, then the previous deploy's region, thenus-central.
Image references are content-addressed by the manifest digest, which is printed alongside the public URL on success. Identical handlers produce identical digests.
Reconciliation reads .zigttp/deploy-state.json, which stores non-secret
identifiers (service_id, scope_id, plan_id, region,
managed_env_keys, last_image_digest) from the last successful
deploy. A second run for the same service reuses the stored service
id and patches in place. A change to scope, region, plan, or removal
of a previously managed env var raises a drift warning; the CLI
prints it and exits with code 2 unless --confirm is passed. Even
with --confirm, the old service is rebound and updated, never
deleted.
After the push the CLI polls the provider until the service reports ready (120s default). Exit codes:
0success2drift detected, re-run with--confirm3timed out waiting for the service to report ready4service failed to start
The same compiler-proven contract used for sandboxing drives the deploy. Proof facts (proof level, env var names, egress hosts, cache namespaces, routes, handler properties) are encoded as JSON arrays in OCI image labels so provenance survives in the registry.
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.
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.
zigts 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. In the TUI, press A on the
selected forge result to apply it. 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).
Spec<...> is the source-level way to demand which compiler-proven properties
a handler must satisfy. It rides the same TS generic-alias machinery as
Result<T>, strips at runtime, and is read by the verifier after the analyzer
pipeline runs. A handler that declares a 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">.
Eleven names are recognized; six 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. - 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.
- Live HUD pane under
zigttp serve --watch --prove: aSpecs (declared)block beneath the inferred properties shows[*] spec NAMEwhen discharged and[-] spec NAMEwhen not. - Proof studio at
/_zigttp/studio: aSpecs (declared)heading after Properties renders each declared 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 --jsonadds adeclared_specsarray and aspec_diagnosticsarray to the proof envelope.zigts 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 author'sSpec<...>is the obligation contract.
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 │
└─────────────────────────────────────────────────────────────────┘