@automattic/vip-edge-workers-sdk
v0.3.0
Published
AssemblyScript SDK for VIP edge-workers
Maintainers
Keywords
Readme
@automattic/vip-edge-workers-sdk
Edge Workers let you run code at the CDN layer — before the cache, after the cache, before the origin, after the origin — with sub-millisecond overhead on every request. This SDK gives you the building blocks to write those workers in AssemblyScript, a TypeScript-flavored language that compiles directly to WebAssembly (WASM).
You write your worker as a set of handlers for whichever HTTP lifecycle phases you care about. The SDK handles all the plumbing — marshalling request and response data in and out of the WASM sandbox, managing the low-level ABI between the host runtime and your code — so you can focus on what your worker actually does: rewriting headers, short-circuiting to an early response, calling an upstream API, reading from a shared key-value store, or enforcing a rate limit.
The compiled .wasm binary is what you deploy. It's typically a few kilobytes, cold-starts in microseconds, and runs in strict isolation — a fresh instance per request with no shared state between tenants.
Install
npm install @automattic/vip-edge-workers-sdkQuick start
import { onClientRequest, onClientResponse } from "@automattic/vip-edge-workers-sdk";
// Re-export the ABI symbols for each phase you handle.
// alloc is always required.
export {
alloc,
on_client_request,
on_client_response,
} from "@automattic/vip-edge-workers-sdk/assembly/index";
onClientRequest((req) => {
if (req.url == "/healthz") {
req.respondText(200, "ok"); // short-circuit: skip origin entirely
return;
}
req.headers.set("x-edge-worker", "true");
});
onClientResponse((resp) => {
resp.headers.set("x-served-by", "edge");
});Phases
The host invokes the worker at up to four points per request:
| Phase | Register with | ABI export | Body marker | When it runs |
| --- | --- | --- | --- | --- |
| Client request | onClientRequest | on_client_request | client_request_body | Before cache lookup. Only phase that can call fetch(). |
| Origin request | onOriginRequest | on_origin_request | origin_request_body | Cache miss, before forwarding to origin. |
| Client response | onClientResponse | on_client_response | client_response_body | After cache lookup, before sending to client. |
| Origin response | onOriginResponse | on_origin_response | origin_response_body | Cache miss, after origin responds. |
Only re-export the ABI symbols for phases your worker handles. alloc is always required. Re-export a body marker only when that phase needs the body buffered (see Body access).
Phase isolation
Each phase handler runs as an isolated invocation. The host marshals the request (or response) into your worker, runs the one handler, and marshals the result back out — then repeats, from a clean slate, for the next phase. Two consequences trip people up:
1. Module state does not survive between phases. A module-level variable set in one handler is back at its initial value by the time the next phase runs. This is the single most common mistake — it looks correct and compiles cleanly, but the flag is always false:
// BROKEN — `tag` is not shared across phases; it resets before onClientResponse runs.
let tag = false;
onClientRequest((req) => { tag = req.url.includes("debug"); });
onClientResponse((resp) => { if (tag) resp.headers.set("x-debug", "1"); }); // never firesSharing data across phases
| You want to… | Do this |
| --- | --- |
| Carry a value between two request phases (onClientRequest → onOriginRequest) | Mutate the request itself — e.g. req.headers.set("x-my-flag", "1"). Request mutations are marshaled forward to the next request phase (this is also how you change what origin sees). |
| Share data across requests | Use KV — the only store that persists beyond a single request. |
API
Registration
onClientRequest((req: Request) => void): void
onOriginRequest((req: Request) => void): void
onClientResponse((resp: Response) => void): void
onOriginResponse((resp: Response) => void): voidMutate req / resp in place. In request phases, call req.respondWith() or req.respondText() to return an early response without hitting origin or cache.
Body access
By default, req.body and resp.body are null — the host streams the body without buffering it. To have the body populated for a given phase, add its body marker to your ABI re-export block:
import { onClientRequest } from "@automattic/vip-edge-workers-sdk";
export {
alloc,
on_client_request,
client_request_body, // ← tells the host to buffer the request body
} from "@automattic/vip-edge-workers-sdk/assembly/index";
onClientRequest((req) => {
const body = req.text(); // populated because the marker is exported
});| Phase | Marker |
| --- | --- |
| Client request | client_request_body |
| Origin request | origin_request_body |
| Client response | client_response_body |
| Origin response | origin_response_body |
The marker is a compile-time signal — its presence in the binary is what matters, not any runtime call. Workers that omit a marker pay no buffering cost for that phase.
Headers
Headers is a WHATWG-style view over a message's headers, exposed as req.headers and resp.headers. It mirrors the browser Headers interface, with two deliberate departures forced by AssemblyScript: it is not iterable (no for…of / spread — use entries() / keys() / values(), which return arrays), and the constructor accepts only [name, value] pairs (new Headers([["accept", "text/plain"]])). Header names are normalised to lowercase, per spec.
Mutations on req.headers / resp.headers apply directly to the in-flight message. A standalone new Headers(...), or the headers on a fetch() result, is backed by a local list.
onClientRequest((req) => {
const ua = req.headers.get("user-agent") || "unknown"; // string | null
req.headers.set("x-ua", ua); // replace all values
req.headers.append("x-tag", "a"); // add without replacing
req.headers.delete("x-internal-token");
if (req.headers.has("authorization")) { /* … */ }
});| Method | Type | Notes |
| --- | --- | --- |
| get(name) | string \| null | Case-insensitive. On a local Headers, repeated values are joined with ", "; on a live message, the host returns the first value |
| has(name) | bool | Whether the header is present |
| set(name, value) | void | Replaces all existing values for that name; throws on invalid name/value or size limit exceeded |
| append(name, value) | void | Appends without removing existing values — use for Set-Cookie, Cookie, Vary, … |
| delete(name) | void | Removes all values for that name; no-op if absent |
| getSetCookie() | string[] | All Set-Cookie values, one per entry |
| entries() | string[][] | All [name, value] pairs, order and duplicates preserved |
| keys() | string[] | All header names, in order |
| values() | string[] | All header values, in order |
| forEach(cb) | void | Calls cb(value, name, parent) for each header |
Request
Request is your view into the incoming HTTP request. You receive it as the argument to onClientRequest and onOriginRequest handlers. Read its method, URL, headers, and body; mutate it in place to change what the upstream or origin sees; or call respondWith() / respondText() to short-circuit the entire request with an immediate reply — skipping cache and origin entirely.
The same class also appears in response phases as the read-only view returned by resp.request: the request as it originally arrived, before your request-phase handlers mutated it. On that view all setters and mutating methods throw, and body is always null.
onClientRequest((req) => {
// Header manipulation
const ua = req.headers.get("user-agent") || "unknown";
req.headers.set("x-ua", ua);
req.headers.delete("x-internal-token");
// Short-circuit with an early response
if (req.url == "/robots.txt") {
req.respondText(200, "User-agent: *\nDisallow: /admin/",
new Headers([["content-type", "text/plain"]]));
return;
}
});| Field / method | Type | Notes |
| --- | --- | --- |
| method | string | HTTP method ("GET", "POST", …) |
| url | string | Request target: path and query string. (Named url to match WHATWG; unlike a browser Request this is origin-relative, not absolute.) |
| headers | Headers | Request headers; mutations apply to the in-flight request |
| body | Uint8Array \| null | Raw request body; null if not buffered by the host |
| text() | string \| null | UTF-8 decode of body; null if body is absent |
| bytes() | Uint8Array \| null | Alias for body |
| arrayBuffer() | ArrayBuffer \| null | Copy of body as an ArrayBuffer; null if absent |
| setBodyText(text) | void | UTF-8 encode and replace body |
| respondWith(response) | void | Short-circuit with a Response (WHATWG FetchEvent.respondWith shape) |
| respondText(status, body?, headers?) | void | Convenience: short-circuit with a string body (UTF-8 encoded). headers is a Headers or null |
| bypassChallenge() | void | Signal host to skip bot-protection for this request |
| forceChallenge() | void | Signal host to present a bot-protection challenge for this request, even if it would not otherwise be challenged |
Note: unlike the WHATWG
fetchAPI,req.text()/req.bytes()/req.arrayBuffer()are synchronous — they return the value directly, not aPromise. AssemblyScript has noasync/await.
Response
Response represents an HTTP response — whether it came from the cache, the origin, or one of your fetch() calls. You receive it in onClientResponse and onOriginResponse handlers, and as the return value from fetch(). You can also construct one with new Response(body, init) to pass to req.respondWith(). Read its status and headers; mutate them to change what the client sees; call setCacheControl() to override how the host caches the response.
onClientResponse((resp) => {
resp.headers.set("x-served-by", "edge");
if (resp.ok) {
resp.setCacheControl("public, max-age=60");
}
});
// Build one to short-circuit a request:
req.respondWith(new Response(null, {
status: 302,
headers: new Headers([["location", "/elsewhere"]]),
}));| Field / getter / method | Type | Notes |
| --- | --- | --- |
| new Response(body?, init?) | — | body is Uint8Array \| null; init is { status?, statusText?, headers? } |
| status | u16 | HTTP status code; 0 on fetch() transport error. Defaults to 200 for a constructed response |
| statusText | string | Advisory only — not sent on the wire by this host |
| ok | bool | true iff status is 200–299 |
| headers | Headers | Response headers |
| request | Request | Read-only view of the original request (response phases only; see below) |
| body | Uint8Array \| null | Raw body bytes; null if not buffered by the host |
| errorKind | string \| null | Transport error type; only set on Response values returned by fetch(), always null in response-phase handlers |
| errorMessage | string \| null | Human-readable detail when errorKind is set |
| text() | string \| null | UTF-8 decode of body; null if body is absent |
| bytes() | Uint8Array \| null | Alias for body |
| arrayBuffer() | ArrayBuffer \| null | Copy of body as an ArrayBuffer; null if absent |
| setBodyText(text) | void | UTF-8 encode and replace body |
| isError() | bool | true iff errorKind is set |
| setCacheControl(value) | void | Override the Cache-Control header for custom cache behaviour |
Note: as with
Request, the body accessors are synchronous and there is noResponse.json()— parsetext()with a JSON library (see thejsonexample).
resp.request → Request (read-only)
Response-phase handlers run after the request has already gone upstream, so resp.headers is the response header map. To decide anything based on what the client asked for, use resp.request: a read-only snapshot of the request as it arrived, taken by the host before any request-phase handler mutated it.
onClientResponse((resp) => {
const req = resp.request; // original request, pre-mutation
if (req.method == "POST") {
resp.setCacheControl("no-store"); // never cache POST responses
}
const origin = req.headers.get("origin");
if (origin !== null && origin.endsWith(".example.com")) {
resp.headers.set("access-control-allow-origin", origin);
}
});Semantics worth knowing:
- Original, not as-sent-upstream. If a request-phase handler rewrote
req.urlor a header,resp.requeststill shows the pre-rewrite values. InonOriginResponsethe snapshot is the request as it reached the origin socket (which already includes client-phase mutations that went through the cache path). - Immutable. Setters,
respondWith/respondText, and header writes throw. To change the response, mutaterespdirectly. bodyis alwaysnull— the host does not retain request bodies across the upstream round-trip.- Free unless used. The view is built lazily, and a worker that never touches
resp.requestcompiles with norequest.*imports at all — the host then skips taking the snapshot entirely. (Requires a runtime withrequest.*host-function support; deploy the runtime before workers that use this.)
HTML rewriting (HtmlRules)
HtmlRules rewrites HTML response bodies without the bytes ever entering your worker. You declare a set of rules — CSS selector plus an action — and call install() once; the host runs them with lol-html as the response streams to the client. The whole rule set crosses the host/guest boundary in a single call, so cost is constant no matter how many rules you add.
Prefer this over manual body editing whenever you can. Because the host rewrites the response as a stream, your worker never buffers the body into WASM memory: there's nothing to allocate, nothing to copy across the sandbox boundary, and no response-size ceiling — a multi-megabyte page rewrites the same way a small one does. Manual editing (export an *_response_body marker, then text() / setBodyText()) forces the host to buffer the entire body so it fits in linear memory, which is slower, costs memory proportional to the response, and is bounded by the host's buffered-body limit. Reach for the manual path only when you genuinely need to compute the change from the body's own content (something no declarative rule can express).
import { onOriginResponse, Response, HtmlRules } from "@automattic/vip-edge-workers-sdk";
export {
alloc,
on_origin_response,
} from "@automattic/vip-edge-workers-sdk/assembly/index";
onOriginResponse((resp: Response): void => {
new HtmlRules()
.prependInside("head", '<script src="/analytics.js" defer></script>')
.setAttr("meta[name=robots]", "content", "noindex")
.removeAttr("a", "ping")
.rewriteAttrRe("a[href]", "href", "^http://", "https://") // force outbound links to https
.remove("script[data-tracker]")
.install();
});Phase and content-type rules:
- Effective only in response-phase handlers (
onClientResponse/onOriginResponse). Rules registered in a request phase are silently dropped. - Applied only when the response
Content-Typeistext/htmlorapplication/xhtml+xml. JSON, images, etc. pass through untouched. - The body never enters the worker, so do not export an
*_response_bodymarker for this — there is nothing to buffer.
| Method | Signature | Action |
| --- | --- | --- |
| insertBefore | (selector, html) | Insert html immediately before each matching element |
| insertAfter | (selector, html) | Insert html immediately after each matching element |
| prependInside | (selector, html) | Insert html as the first child of each match |
| appendInside | (selector, html) | Insert html as the last child of each match |
| replace | (selector, html) | Replace each match (and its children) with html |
| remove | (selector) | Remove each match (and its children) |
| setAttr | (selector, name, value) | Set attribute name to value on each match |
| removeAttr | (selector, name) | Remove attribute name from each match |
| rewriteAttrRe | (selector, name, pattern, template) | Rewrite attribute name via pattern.replace_all(template) |
| setText | (selector, text) | Replace the text content of each match (HTML-escaped) |
| install | () → void | Submit the accumulated rules. Throws on any host validation error |
Notes:
- Selectors use lol-html's CSS subset —
a[href],a[href^="/"],div.cls,#id,header > nav, and most position pseudo-classes. htmlarguments are inserted verbatim, not escaped. Escape any user-controlled content yourself before passing it in.setTextis the exception — itstextis HTML-escaped by the host.rewriteAttrReuses Rustregex(RE2-style, no lookarounds). Templates support numbered captures ($1..$9), named captures (${name}), the whole match ($0), and$$for a literal$. The attribute is left untouched if absent or if the replacement is a no-op.- The builder is chainable; call
install()once per worker. Twoinstall()calls queue two separate rule sets. - Limits: ≤ 256 rules and ≤ 64 KiB total payload per call.
install()throws on an oversize payload, an invalid selector, or a bad regex pattern.
fetch(url, init?) → Response
Make an outbound HTTP request from the worker. Modelled on WHATWG fetch(url, init), but — because AssemblyScript has no async — it returns a Response directly, not a Promise. From your code's perspective it's a regular synchronous call: the host suspends the WASM instance while the request is in flight and resumes it with the response. Errors are surfaced via fields rather than a rejected promise, so always check resp.isError() before reading the body.
Available in the client-request phase only. Calling it from any other phase returns a Response with errorKind set to "phase_not_allowed".
import { fetch, Headers, onClientRequest } from "@automattic/vip-edge-workers-sdk";
onClientRequest((req) => {
if (!req.url.startsWith("/ip")) return;
const resp = fetch("https://ifconfig.me/ip", {
headers: new Headers([["accept", "text/plain"]]),
});
if (resp.isError()) {
req.respondText(502, "fetch failed: " + resp.errorKind!);
return;
}
req.respondText(resp.status, resp.text(), new Headers([["content-type", "text/plain"]]));
});To send a request body:
const body = Uint8Array.wrap(String.UTF8.encode('{"key":"value"}'));
const resp = fetch("https://api.example.com/v1/data", {
method: "POST",
headers: new Headers([["content-type", "application/json"]]),
body,
});init is a RequestInit — an options bag you can pass as an object literal:
| init field | Type | Default |
| --- | --- | --- |
| method | string | "GET" |
| headers | Headers \| null | null |
| body | Uint8Array \| null | null |
errorKind values: timeout, too_many_inflight, request_too_large, response_too_large, body_read, transport, phase_not_allowed.
Host-enforced limits: 2 s end-to-end timeout · 10 MiB request body · 10 MiB response body · per-worker concurrency cap (FIFO queue).
KV
KV is a shared, host-backed key-value store. Unlike in-memory variables in your worker code — which are wiped at the end of every request because the WASM instance is discarded — KV persists data across requests and is shared across all instances running on the same site. It's useful for counters, feature flags, small configuration blobs, or any value that needs to outlive a single request.
The store has two distinct modes. Bytes holds arbitrary values up to 10 KiB. Counters hold atomic 64-bit integers — useful for visit counts, quotas, or any value you need to increment safely under concurrent load. A key can only hold one type at a time; mixing them throws.
Keys: max 64 bytes (UTF-8). Values: max 10 KiB. Entries are subject to site-wide LRU eviction; evicted counters reset to 0 on next access.
Bytes store
import { KV, onClientRequest } from "@automattic/vip-edge-workers-sdk";
// PUT reads req.text() — export client_request_body to have the host buffer it.
onClientRequest((req) => {
if (req.method == "PUT" && req.url == "/note") {
KV.setText("note", req.text() || "");
req.respondText(200, "stored");
return;
}
if (req.method == "GET" && req.url == "/note") {
const note = KV.getText("note");
req.respondText(note !== null ? 200 : 404, note || "no note stored");
return;
}
});| Method | Signature | Notes |
| --- | --- | --- |
| get | (key: string) → Uint8Array \| null | null = absent or counter key; zero-length = key exists with empty value |
| set | (key: string, value: Uint8Array) → void | Throws if key > 64 B or value > 10 KiB |
| del | (key: string) → void | No-op if absent; works on both types |
| getText | (key: string) → string \| null | get + UTF-8 decode |
| setText | (key: string, value: string) → void | UTF-8 encode + set |
Counter store
Atomic 64-bit integers. Absent counters read as 0 and are created on first write.
onClientRequest((req) => {
const visits = KV.incr("visits"); // atomic +1, returns new value
req.headers.set("x-visit-count", visits.toString());
});| Method | Signature | Notes |
| --- | --- | --- |
| counterGet | (key: string) → i64 | 0 if absent; throws if key holds bytes |
| counterSet | (key: string, value: i64) → void | Throws if key > 64 B or key holds bytes |
| incr | (key: string, delta: i64 = 1) → i64 | Atomic add; returns new value; initialises to 0 if absent; throws if key holds bytes |
RateLimit
RateLimit lets you enforce request rate limits at the edge, before a request ever reaches your origin. You define a named limiter with a target rate in requests per second, then check each incoming request against a key — typically a client IP or authenticated user ID. Each unique key gets its own independent token bucket, so the limit applies per-client rather than globally. Limiters are shared across all instances on the same site, so the rate is enforced consistently even under concurrent load.
register is idempotent and safe to call on every request. The rps value is fixed at the first register call; later calls with the same name are no-ops regardless of what rps you pass.
import { RateLimit, onClientRequest } from "@automattic/vip-edge-workers-sdk";
onClientRequest((req) => {
if (!req.url.startsWith("/api/")) return;
RateLimit.register("api", 50);
const ip = req.headers.get("x-forwarded-for") || "";
if (!RateLimit.check("api", ip)) {
req.respondText(429, "rate limited");
return;
}
});| Method | Signature | Notes |
| --- | --- | --- |
| register | (name: string, rps: i32) → void | Idempotent. Throws if name > 64 B, rps ≤ 0, or site-wide limiter quota (1000) exceeded |
| check | (name: string, key: string) → bool | true = allowed, false = rate-limited. Throws if limiter not registered |
Choosing the key: each unique key gets its own token bucket. Key on something an attacker cannot enumerate freely — client IP, authenticated user ID, a normalised path prefix. Never use req.url directly; arbitrary query strings create unlimited unique buckets and defeat the limit.
| Good | Bad |
| --- | --- |
| req.headers.get("x-forwarded-for") | req.url — query strings make every request unique |
| Authenticated user ID | Any user-supplied header or body value |
| req.url.split("?")[0] — normalised path | req.url with arbitrary query params |
getenv(name) → string | null
Look up a host-provided environment variable. Variables are read-only and set at worker startup. Returns null if not set.
import { getenv, onClientRequest } from "@automattic/vip-edge-workers-sdk";
onClientRequest((req) => {
const key = getenv("API_KEY");
if (key !== null) req.headers.set("authorization", "Bearer " + key);
});Crypto
Host-executed cryptographic primitives. No hash or HMAC code lives in the WASM binary — your worker binary stays small and the host handles all crypto operations.
HMAC-SHA256 / webhook signature verification
import {
getenv, onClientRequest,
hmacSha256Str, secureCompare, hexEncode,
} from "@automattic/vip-edge-workers-sdk";
export {
alloc, on_client_request, client_request_body,
} from "@automattic/vip-edge-workers-sdk/assembly/index";
onClientRequest((req) => {
const secret = getenv("WEBHOOK_SECRET");
if (secret === null) { req.respondText(500, "not configured"); return; }
const sig = req.headers.get("x-hub-signature-256") || "";
const expected = "sha256=" + hexEncode(hmacSha256Str(secret, req.text() || ""));
const a = Uint8Array.wrap(String.UTF8.encode(sig));
const b = Uint8Array.wrap(String.UTF8.encode(expected));
if (!secureCompare(a, b)) { req.respondText(401, "invalid signature"); return; }
});HS256 JWT verification
import { getenv, onClientRequest, jwtVerifyHs256 } from "@automattic/vip-edge-workers-sdk";
onClientRequest((req) => {
const auth = req.headers.get("authorization") || "";
if (!auth.startsWith("Bearer ")) { req.respondText(401, "missing token"); return; }
const claims = jwtVerifyHs256(auth.slice(7), getenv("JWT_SECRET") || "");
if (claims === null) { req.respondText(401, "invalid or expired token"); return; }
// claims is the raw JSON payload — parse with json-as if you need specific fields,
// or forward it to the origin as a header.
req.headers.set("x-jwt-claims", claims);
});| Function | Signature | Notes |
| --- | --- | --- |
| hmacSha256 | (key: Uint8Array, msg: Uint8Array) → Uint8Array | 32-byte HMAC-SHA256 digest |
| hmacSha256Str | (key: string, msg: string) → Uint8Array | Convenience: UTF-8 encodes both inputs |
| sha256 | (msg: Uint8Array) → Uint8Array | 32-byte SHA-256 digest |
| secureCompare | (a: Uint8Array, b: Uint8Array) → bool | Timing-attack-safe comparison; execution time depends only on input length, not content |
| jwtVerifyHs256 | (token: string, secret: string) → string \| null | Verifies alg, signature, and exp; returns claims JSON or null |
| hexEncode | (bytes: Uint8Array) → string | Lowercase hex string (two characters per byte) |
jwtVerifyHs256 validates alg: "HS256" strictly — RS256 / ES256 tokens return null. exp is checked against wall clock; nbf and iat are not enforced.
Secrets should come from getenv() — injected at deploy time, never visible in the binary or on the wire.
Parsing JSON
The SDK does not include a JSON library. Workers that need to parse structured JSON bodies add json-as directly to their own package.json and pass --transform json-as to asc. Workers that don't parse JSON pay nothing — no serialization code is included in their binary.
import { JSON } from "json-as";
import { fetch, onClientRequest } from "@automattic/vip-edge-workers-sdk";
@json
class Todo {
id: i32 = 0;
title: string = "";
completed: bool = false;
}
@json
class Summary {
id: i32 = 0;
title: string = "";
done: bool = false;
}
onClientRequest((req) => {
if (!req.url.startsWith("/todo/")) return;
const resp = fetch("https://jsonplaceholder.typicode.com/todos/" + req.url.slice(6));
if (!resp.ok) { req.respondText(resp.status, "not found"); return; }
const todo = JSON.parse<Todo>(resp.text()!);
const out = new Summary();
out.id = todo.id;
out.title = todo.title;
out.done = todo.completed;
req.respondText(200, JSON.stringify(out), [["content-type", "application/json"]]);
});Building code
Your worker source is AssemblyScript — a TypeScript-flavored language that compiles to WebAssembly bytecode using asc, the AssemblyScript compiler. Unlike TypeScript, which transpiles to JavaScript, AssemblyScript compiles all the way down to a .wasm binary with no JavaScript runtime dependency. The result is a small, self-contained file that the host loads and executes directly.
One compiler flag is always required, and two are strongly recommended for release builds:
--runtime stubuses a simple bump allocator with no garbage collector. Each request starts with a fresh heap and all memory is reclaimed when the request ends — there is nothing for a GC to manage.--optimizeLevel 3runs the full Binaryen optimiser. Without it, binaries are significantly larger and slower.--shrinkLevel 2tells the optimiser to prefer size over speed (-Ozequivalent) — inlines less, merges similar code. Typically cuts an additional 15–25% off the release binary.
If your worker parses JSON bodies, also add --transform json-as (see Parsing JSON).
// build.mjs
import { execFileSync } from 'child_process';
import { mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const root = dirname(fileURLToPath(import.meta.url));
const nodeModules = resolve(root, 'node_modules');
const { NODE_OPTIONS: _drop, ...env } = process.env;
mkdirSync(resolve(root, 'build'), { recursive: true });
execFileSync(resolve(nodeModules, '.bin/asc'), [
resolve(root, 'assembly/index.ts'),
'--outFile', resolve(root, 'build/release.wasm'),
'--runtime', 'stub',
'--optimizeLevel', '3',
'--shrinkLevel', '2',
'--path', nodeModules,
], { cwd: nodeModules, env, stdio: 'inherit' });Working examples under examples/:
| Example | What it shows |
| --- | --- |
| fetch | Outbound fetch() — proxy a route to an external API |
| kv | KV bytes store + atomic counter forwarded to origin as a header |
| rate-limit | Per-path rate limiting with RateLimit |
| json | Parse a JSON API response into a typed @json class |
| html-rewrite | Declarative HTML rewriting with HtmlRules — anonymize outbound links via href.li |
| redirect | 301 redirects — rename a path prefix and strip trailing slashes |
| rewrite | Internal rewrite — repoint host + URI before cache lookup, no client-visible redirect |
| headers | Override Cache-Control on the origin response + tag .pdf responses using the resp.request view |
| webhook | HMAC-SHA256 webhook signature verification (GitHub style) |
| udger | Device/crawler detection via the Udger Cloud Parser API + getenv secret |
Run make examples to build all of them.
Notes
Bodies are opt-in. req.body and resp.body are null by default; see Body access to enable buffering per phase. fetch() response bodies are always available. Use text() / bytes() to read; setBodyText() to replace.
Bodies are raw bytes (Uint8Array). Use text() / setBodyText() for UTF-8 strings; JSON.parse (via json-as) for structured JSON; everything else (binary, compressed, …) is plain bytes.
Headers — accessed via req.headers / resp.headers, a WHATWG-style Headers object. set replaces all existing values for a name; append adds a value alongside existing ones (use for Set-Cookie, Vary, etc.); get returns the value (joining duplicates with ", " on a local Headers); entries() returns all pairs in order. Header names are normalised to lowercase. Name limit: 1 KiB; value limit: 64 KiB — set and append throw if either is exceeded.
Memory model (--runtime stub). The stub runtime is a bump allocator — no GC, memory is never freed within a single phase invocation. Each phase invocation starts from a clean slate: no state is carried over from a prior phase, a prior request, or another worker — full isolation is guaranteed. Because nothing survives from one phase to the next, module-level variables cannot be used to pass data between phases — see Phase isolation for how to share data correctly.
