Skip to content

Latest commit

 

History

History
2347 lines (1811 loc) · 74.9 KB

File metadata and controls

2347 lines (1811 loc) · 74.9 KB

zigttp User Guide

A serverless JavaScript runtime for FaaS deployments (AWS Lambda, Azure Functions, Cloudflare Workers), powered by Zig and zigts.


Table of Contents

  1. Installation
  2. Quick Start
  3. Command Line Reference
  4. Handler API
  5. Request Object
  6. Response Object
  7. Routing Patterns
  8. Working with JSON
  9. Error Handling
  10. Virtual Modules
  11. JavaScript Subset Reference
  12. TypeScript Support
  13. Complete Examples
  14. Performance Tuning
  15. Compile-Time Verification
  16. Contract Manifest
  17. OpenAPI Manifest
  18. TypeScript SDK
  19. Runtime Sandboxing
  20. Declarative Handler Testing
  21. Route Forge with zigts expert
  22. Author-Declared Specs
  23. Troubleshooting

Installation

Prerequisites

  • Zig 0.16.0: Download from ziglang.org. Newer compiler releases are best-effort until revalidated.

Build

# 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 --help

Deployment Package

The resulting binary (~500KB) has zero runtime dependencies and can be deployed directly to FaaS platforms or container environments.


Quick Start

The v1 user flow is init → edit → studiobuilddeploy --local. Each command auto-detects the project from zigttp.json, so most steps take no arguments.

Scaffold a project

zigttp init my-app && cd my-app

This creates src/handler.ts, tests/handler.test.jsonl, public/, zigttp.json, a starter README.md, and a .gitignore.

Open the proof workbench

zigttp studio

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

Build a self-contained binary

zigttp build
./.zigttp/build/my-app

Verified local deploy

zigttp deploy --local
./.zigttp/deploy/my-app

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

Quick one-off testing

For inline experimentation outside a project:

zigttp serve -e "function handler(req) { return Response.text('Hello World!') }"
zigttp serve hello.js

Multi-handler edge

zigttp 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.json

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


Command Line 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

Examples

# 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}) }"

Handler API

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.


Request Object

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
}

Accessing Request Properties

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, or null.
  • request.text() returns the raw body string, or "" when no body is present.
  • request.json() returns parsed JSON, or undefined when the body is empty or invalid JSON.
  • request.text() and request.json() are single-use body readers. After either one runs, further body reads throw. Use request.body if you need the raw string without consuming it.

Common Header Access

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,
    });
}

Response Object

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.

Response Helpers

Response.text(body, init?)

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 });

Response.json(data, init?)

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" },
});

Response.text(text, init?)

Create a plain text response. Sets Content-Type: text/plain.

Response.text("Hello World");
Response.text("Error occurred", { status: 500 });

Response.html(html, init?)

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.

HTTP Status Codes

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

Routing Patterns

Simple Path Matching

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 });
}

Method-Based Routing

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 });
}

Path Parameters (Manual Extraction)

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: [] });
}

Prefix Matching

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>));
}

Router Helper Function

// 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);
}

Match Expression Routing

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 to default)
  • 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.


Working with JSON

Parsing JSON Request Body

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,
        });
    }
}

Building JSON Responses

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);
}

JSON Validation Helper

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 });
}

Error Handling

zigts has no try/catch. All errors flow through two patterns: Result types and optional narrowing.

Result Types

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 });
}

Optional Narrowing

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 });
}

Error Response Helper

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 });
}

Virtual Modules

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.


JavaScript Subset Reference

zigts implements a restricted JavaScript subset optimized for FaaS workloads. The restrictions enable compile-time verification, deterministic replay, and contract extraction.

Supported Features

// 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);

NOT Supported (compile-time errors)

All unsupported features produce helpful error messages with alternatives:

  • class - use plain objects and functions
  • var - use let or const
  • while, do...while - use for (const x of range(n))
  • C-style for (;;) - use for (const i of range(n))
  • for...in - use for (const k of Object.keys(obj))
  • try/catch/throw - use Result types (check .ok)
  • async/await/Promise - use fetchSync(), parallel(), race()
  • null - use undefined
  • ==, != - use ===, !==
  • ++, -- - use x = x + 1
  • this, new - use explicit params and factory functions
  • delete - use const { key, ...rest } = obj
  • Regular expressions - use string methods
  • any type (TS) - use specific types
  • as type assertions (TS) - use control flow narrowing

See feature-detection.md for the full 54-feature detection matrix.

Strict Mode

zigts always runs in strict mode. Implicit globals and with are errors.


TypeScript Support

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.

How It Works

The TypeScript stripper performs a single-pass transformation:

  1. Removes type annotations (: Type, as Type)
  2. Removes interface and type declarations
  3. Removes generics (<T>) and generic type aliases (type Result<T> = ...)
  4. Preserves all runtime code unchanged
  5. 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.

Basic TypeScript Handler

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

TSX for Server-Side Rendering

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));
}

Compile-Time Evaluation with comptime()

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

Basic Usage

// 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];

Hash Function

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 },
    });
}

JSON Parsing

Parse JSON at compile time:

const config = comptime(JSON.parse('{"debug":false,"maxItems":100}'));
// -> const config = ({debug:false,maxItems:100});

Method Chaining

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";

comptime in TSX

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>

Supported comptime Operations

Literals

  • Numbers: 42, 3.14, -1, 0xFF
  • Strings: "hello", 'world'
  • Booleans: true, false
  • Special: null, undefined, NaN, Infinity

Operators

Type Operators
Unary + - ! ~
Arithmetic + - * / % **
Bitwise | & ^ << >> >>>
Comparison == != === !== < <= > >=
Logical && || ??
Ternary cond ? a : b
Pipe a |> f (desugars to f(a))
Compound += -= *= /= %= **= &= |= ^= <<= >>= >>>=

Math Constants

  • Math.PI, Math.E, Math.LN2, Math.LN10
  • Math.LOG2E, Math.LOG10E, Math.SQRT2, Math.SQRT1_2

Math Functions

  • abs, floor, ceil, round, trunc
  • sqrt, cbrt, pow, exp, log, log2, log10
  • sin, cos, tan, asin, acos, atan, atan2
  • min, max, sign, hypot
  • clz32, imul, fround

String Properties and Methods

  • .length
  • toUpperCase(), 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?)

Array Properties

  • .length

Built-in Functions

  • parseInt(str, radix?), parseFloat(str)
  • JSON.parse(str) - parses JSON string to comptime value
  • hash(str) - FNV-1a hash, returns 8-char hex string

comptime Errors

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 allowed

Error messages include line and column information for debugging.

Performance Benefits

  1. Zero-cost types: Type annotations are stripped with no runtime overhead
  2. Pre-computed values: comptime() shifts computation from runtime to load time
  3. Smaller output: Type declarations don't appear in the stripped output
  4. Single-pass: Stripping happens in one pass, no AST construction

Complete Examples

REST API Server

// 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;
}

HTML Web Application

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 });
}

Health Check / Metrics Endpoint

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,
    });
}

Performance Tuning for FaaS

Benchmarks

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.

Memory Configuration

# 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.js

Cold Start Optimization

zigttp is optimized for FaaS cold starts:

  • Binary initialization: < 1ms
  • Handler loading: typically < 5ms
  • No JIT warm-up required by default

Hybrid Arena Allocation

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.

Optimize Handler Code

// 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);
}

Production Deployment

Server Options

CLI options for the standalone server:

zigttp serve -p 8080 -h 127.0.0.1 -n 8 --cors --static ./public handler.js

Advanced 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,
};

Standalone Server

# Quiet mode, bind to all interfaces
./zig-out/bin/zigttp serve -q -h 0.0.0.0 -p 8080 handler.js

Native Deploy CLI

zigttp 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 warning

Flags (all optional):

  • --region <name> overrides the deployment region for this run.
  • --confirm acknowledges drift detected in .zigttp/deploy-state.json and proceeds with a replace-like update.
  • --wait (default) blocks until the service reports ready.
  • --no-wait returns immediately after the deploy is accepted.
  • -h / --help prints 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 under src/.
  • Service name: the name field in package.json, then the basename of the git origin remote, then the current directory name. Slugified to lowercase with dashes.
  • Runtime environment: KEY=value pairs from .env in the current directory, one per line. Missing file is fine; a malformed line aborts the deploy with a path:line diagnostic.
  • Region: --region if given, then the previous deploy's region, then us-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:

  • 0 success
  • 2 drift detected, re-run with --confirm
  • 3 timed out waiting for the service to report ready
  • 4 service 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.

Docker Container

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"]

AWS Lambda (Custom Runtime)

# 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::...

Cloudflare Workers (via Wasm)

Build with wasm32 target for edge deployment (experimental).


Compile-Time Verification

zigttp can statically prove your handler is correct at build time. Add -Dverify to any build command:

zig build -Dhandler=handler.ts -Dverify

The verifier checks six properties:

  1. Exhaustive returns - every code path through the handler returns a Response
  2. Result safety - Result values from jwtVerify, decodeJson, decodeForm, decodeQuery, etc. have .ok checked before .value is accessed
  3. Unreachable code - statements after unconditional returns are flagged (warning)
  4. Unused variables - declared variables that are never referenced (warning, suppress with _ prefix)
  5. Non-exhaustive match - match expressions without a default arm (warning)
  6. Optional safety - optional values from env(), cacheGet(), parseBearer(), and routerMatch() 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.

Contract Manifest

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 -Dcontract

The 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 of signal()/signalAt())
  • Durable workflow proof data: workflowId, proofLevel, extracted nodes, and extracted edges for run() 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_safe is 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 -Dcontract

The 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 -Dcontract

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

OpenAPI Manifest

Add -Dopenapi to emit a compiler-derived openapi.json alongside the embedded bytecode:

zig build -Dhandler=handler.ts -Dopenapi

The current emitter only includes facts the compiler can prove:

  • schemaCompile("name", JSON.stringify({...})) schemas become component schemas
  • validateJson("name", ...), coerceJson("name", ...), and decodeJson("name", ...) become JSON request bodies
  • decodeForm("name", ...) becomes an application/x-www-form-urlencoded request body
  • decodeQuery("name", ...) contributes typed query parameters
  • parseBearer() / jwtVerify() enable bearer auth metadata
  • routerMatch() 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 -Dopenapi

When 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

TypeScript SDK

Add -Dsdk=ts to emit a dependency-free TypeScript client beside the embedded handler:

zig build -Dhandler=examples/routing/api-surface.ts -Dsdk=ts

The 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.zig

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

Handler Effect Properties

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

Runtime Sandboxing

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.

How It Works

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 a CapabilityPolicyError.
  • 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)

Explicit Policy Override

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.

Contract-Aware Startup

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:

  1. 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-check during development.

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

  3. Property logging. Proven handler properties (retry_safe, deterministic, injection_safe, etc.) are logged at startup for operator visibility.

  4. Response memoization. When the contract proves the handler is pure or deterministic+read_only, GET/HEAD responses are cached in memory and served without entering JS on subsequent identical requests. Cached responses include an X-Zigttp-Proof-Cache: hit header. The cache uses FIFO eviction (default 1024 entries, 5-minute TTL, 256KB max body). Requests with Cache-Control: no-cache or no-store bypass the cache.

Non-Precompiled Handlers

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.


Declarative Handler Testing

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.

Running Tests

# 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.jsonl

Test Format

Each 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 name
  • request - HTTP request (method, url, headers, body). Use null for absent body (JSON has no undefined)
  • io - Virtual module stub. The seq field orders multiple stubs within a test. The handler receives this recorded return value instead of calling the real module
  • expect - Assertions: status (exact match) and/or bodyContains (substring match)

Deterministic Replay

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

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


Route Forge with zigts expert

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

Author-Declared Specs

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

v1 spec names

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.

Diagnostics

  • ZTS500 - spec_not_discharged: the corresponding HandlerProperties field is false. Cause-only specs include a Try: 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 of zigttp:cache or zigttp:sql writes. 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.

Where declared specs surface

  • Live HUD pane under zigttp serve --watch --prove: a Specs (declared) block beneath the inferred properties shows [*] spec NAME when discharged and [-] spec NAME when not.
  • Proof studio at /_zigttp/studio: a Specs (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>.json and 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 swap and deploy event records declaredSpecs: [{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 --json adds a declared_specs array and a spec_diagnostics array to the proof envelope.
  • zigts expert: the pi_specs_status tool returns the active set and discharge state for a handler. Drive pi_repair_plan from this tool's output rather than from the --goal CLI flag - the author's Spec<...> is the obligation contract.

The /specs <handler.ts> slash command is the REPL shortcut that calls pi_specs_status directly.

Troubleshooting

Common Errors

"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);
}

Debugging

// 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");
}

Memory Issues

If you see out-of-memory errors:

  1. Increase memory limit: -m 512k or -m 1m
  2. Reduce object creation in hot paths
  3. Avoid storing large amounts of data in letiables

Quick Reference Card

┌─────────────────────────────────────────────────────────────────┐
│                      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               │
└─────────────────────────────────────────────────────────────────┘