Worker SDK reference
The API a TypeScript Worker is written against. The runtime is Cloudflare-Workers-shaped: Request, Response, URL, Headers, fetch, crypto and the rest are ambient globals (declared in globals.d.ts), provided by the runtime the way a browser/Worker provides them. The SDK module tinys3-worker.ts adds only a few conveniences on top (a Router, a json() shortcut, and a pure-JS jwt helper); both files live in workers/examples/js-worker.
The engine is QuickJS-ng, not V8 — it covers a large slice of modern ECMAScript, and the web platform surface below is implemented on top of it. Anything in Not supported yet is genuinely absent.
The handler contract
A Worker is a default export with a fetch method — the same shape as a Cloudflare Worker.
ts
export type ExportedHandler<E = Env> = {
migrations?: string[]; // schema, applied once at deploy time
fetch(request: Request, env: E, ctx: ExecutionContext): Response | Promise<Response>;
queue?(batch: MessageBatch, env: E, ctx: ExecutionContext): Promise<void>; // see Consuming a queue
};The service-worker form is also supported: addEventListener('fetch', (event) => event.respondWith(...)). If a default export with a fetch method is present it is used; otherwise the first fetch listener that calls respondWith wins. A worker with neither gets a 500.
| Argument | What it is |
|---|---|
request | The incoming Request — method, url, headers, body. |
env | Bindings: env.DB, env.db(slug), env.queue, env.kv(slug), env.S3, env.CACHE, env.ASSETS (manifest), env.SECRETS, plus each secret flattened onto env.NAME, plus env.NOW/env.RAND. |
ctx | ExecutionContext: waitUntil, passThroughOnException. |
fetch may be async. Return a Response; returning a plain object or string is coerced to a JSON or text response.
migrations
An optional array of SQL statements applied once at deploy time (tracked in a _migrations table) so the app provisions its own schema — no setup route to call. Append-only: adding statements and redeploying runs only the new ones.
Each statement is recorded in the same step that runs it, so a statement is never left applied but unrecorded. A failed statement is not recorded; the deploy succeeds with a migrationError, and the next deploy retries from it. Migrations have a 5-minute budget per deploy, separate from the request deadline, so an index on a large table can be built. When the budget runs out, the statement in progress still finishes and is recorded, and the rest run on the next deploy. Use CREATE INDEX IF NOT EXISTS / DROP INDEX IF EXISTS to make a statement safe to rerun.
ts
export default {
migrations: ["CREATE TABLE IF NOT EXISTS users (id BIGINT, name VARCHAR) ENGINE = MergeTree ORDER BY (id)"],
fetch: (req, env, ctx) => router.handle(req, env, ctx),
};Request
A standard Request. The body is binary-safe — arrayBuffer(), bytes(), blob(), formData(), text() and json() all work, and the raw bytes survive round-trips (they are not forced through UTF-8).
ts
const body = (await request.json()) as { name: string };
const raw = await request.bytes(); // Uint8Array
const form = await request.formData(); // multipart or urlencodedrequest.headers is a real Headers object: request.headers.get("cookie"), .has(...), iteration, and getSetCookie(). For convenience and backward compatibility, lower-cased header names are also exposed as properties, so request.headers.cookie reads the Cookie header.
request.url is absolute
BREAKING CHANGE
request.url is now a fully-qualified absolute URL, not a path. The origin is reconstructed from the request Host header and scheme (https when the connection is TLS or terminated TLS at a trusted proxy), and the path is app-relative: a request to /apps/<app>/x is seen by the worker as https://<host>/x. The /apps/<app> prefix (or the custom-domain host) is stripped before the worker sees it.
Migrate any code that treated request.url as a path:
ts
// before — url was "/category/Audio?limit=5"
const path = request.url.split("?")[0];
// after — url is "https://host/category/Audio?limit=5"
const path = new URL(request.url).pathname;The SDK Router already does this. Only hand-rolled routing needs changing.
Response
A standard Response with Response.json(...), Response.redirect(...), Response.error(), status, statusText, ok, headers, and the binary-safe body accessors. Bodies are binary-safe, so you can return a Uint8Array, ArrayBuffer, Blob, stream, or string.
ts
return new Response(pngBytes, { headers: { "content-type": "image/png" } });
return Response.json({ ok: true }, { status: 201 });json(data, init) from the SDK is a shortcut for Response.json(data, init).
Multiple Set-Cookie headers are preserved end-to-end — append several and each is emitted as its own header (the host scopes every one; see Cookies & sessions). This is the one header where repetition is kept; other repeated headers are comma-joined per HTTP.
Cloudflare-compatible globals
The runtime wires the following web-platform globals. They are implemented in-engine, not delegated to a browser.
| Global | Notes |
|---|---|
Request, Response, Headers | Headers keeps multi-value semantics, getSetCookie(), and legacy property access (headers.cookie). |
URL, URLSearchParams | WHATWG parsing, path normalization, canParse/parse. |
TextEncoder, TextDecoder, TextEncoderStream, TextDecoderStream | UTF-8 only. |
Blob, File, FormData | Multipart and x-www-form-urlencoded serialize/parse. |
ReadableStream, WritableStream, TransformStream | Streaming API surface (buffered under the hood — see Not supported yet). |
AbortController, AbortSignal, Event, EventTarget | Incl. AbortSignal.any/timeout/abort. |
structuredClone | Deep clone with cycle handling. |
DOMException | |
atob, btoa | Latin-1 strings, as in browsers. |
setTimeout, setInterval, clearTimeout, clearInterval, scheduler.wait | The run waits for a due timer, but only inside its wall-clock deadline; timers still pending when the response and waitUntil work are done are dropped, as on Cloudflare. |
CompressionStream, DecompressionStream | gzip, deflate, deflate-raw. Done by the host on the whole body (buffered); decompressed output is capped at 32 MiB. |
navigator.userAgent | "TotisWorkers". |
crypto | getRandomValues, randomUUID, crypto.subtle. |
fetch | Opt-in, SSRF-guarded. |
console | log/info/warn/error/debug (messages capped at 4000 chars). |
addEventListener('fetch', …) / removeEventListener | Service-worker handler form. |
ctx (ExecutionContext)
ts
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}ctx.waitUntil(p)registers a promise. Unlike Cloudflare, it is not background work: the run does not end until every registered promise settles, and the worker's deadline (the request timeout) still bounds it. In effect it delays the response until the work completes. Use it for must-finish side effects, not for fire-and-forget. A rejected promise is logged, not surfaced to the client.ctx.passThroughOnException()is accepted but is a no-op — there is no origin to pass through to.
env.DB — D1 API
env.DB is a Cloudflare D1-shaped client over the app's own SQL engine (apps/<appId>/_data_/db). Isolated per app; no connection string.
Other databases in the project: env.db(slug)
env.db("<slug>") returns the same D1-shaped client for any database in the app's own project and environment, addressed by its slug. The host resolves (org, project, env, slug); a database in another project, environment or organisation is reported as database not found: <slug>, the same as a slug that does not exist, so an app cannot probe outside its scope. env.DB is the shortcut for the app's own database — it is the one database bound to the app under the name DB; every other database is reached with env.db(slug).
ts
const shop = env.db("shop");
const { results } = await shop.prepare("SELECT * FROM users WHERE id = ?").bind(42).all();ts
interface D1Database {
prepare(sql: string): D1PreparedStatement;
batch(statements: D1PreparedStatement[]): Promise<D1Result[]>;
exec(sql: string): Promise<{ count: number; duration: number }>;
// legacy, pg-shaped — still supported:
query(sql: string, params?: unknown[]): Promise<{ rows: Record<string, unknown>[]; rowCount: number; columns: string[] }>;
}
interface D1PreparedStatement {
bind(...values: unknown[]): D1PreparedStatement;
first<T = unknown>(column?: string): Promise<T | null>;
all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
run<T = Record<string, unknown>>(): Promise<D1Result<T>>;
raw<T = unknown[]>(options?: { columnNames?: boolean }): Promise<T[]>;
}ts
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(id).first();
const { results } = await env.DB.prepare("SELECT * FROM users WHERE active = ?").bind(1).all();
await env.DB.prepare("INSERT INTO users (id, name) VALUES (?, ?)").bind(id, name).run();Parameter binding
Binding is done on the host (SqlBinder), not in the engine: each ? / ?NNN placeholder is replaced by a typed SQL literal, and placeholders inside string literals, quoted identifiers and comments are left alone — a value can never splice SQL. JS values map to SQL by type: string → quoted+escaped, number/bigint → numeric literal, boolean → 0/1, null → NULL, Date → ISO-8601 string, ArrayBuffer/typed array → base64 text (see blob note below). undefined throws D1_TYPE_ERROR. Positional (?) and numbered (?1, ?2) placeholders are both supported; a mismatch between placeholders and bound values throws D1_ERROR.
batch is atomic
env.DB.batch([...]) wraps the prepared statements in a single BEGIN … COMMIT under the engine's write lock, so it is all-or-nothing: a failing statement rolls the whole batch back.
Result shape
all()/run() return { results, success, meta }. results is rows as objects keyed by column name. meta carries duration, changes, last_row_id, changed_db, rows_read, rows_written, size_after.
Known gaps vs D1
- Rowid aliasing only covers tables created on the D1 dialect. A single-column
INTEGER PRIMARY KEYgets an id when anINSERTomits it (max + 1, SQLite's reuse rule), andmeta.last_row_idreports it.AUTOINCREMENTgives ids that only grow and are never reused, even after the highest row is deleted. Tables created before an app moved to the D1 dialect have no rowid alias recorded: inserts that omit the key fail until the table is recreated (copy the rows into a new table and drop the old one). _ROWIDis reserved. Every row table has a hidden_ROWIDcolumn.SELECT *leaves it out, and a column with that name is rejected atCREATE TABLE.- Errors use SQLite wording where an app might match on it. You get
no such table: X,UNIQUE constraint failed: …,NOT NULL constraint failed: …,CHECK constraint failedandnear "…": syntax error. Any other engine error comes back asSQL statement failed: SQLITE_ERROR; the full error goes to the server log. - Backslash is not an escape in string literals (
'it''s', as in SQLite). - No native
BLOBtype. Binary bound values are stored as their base64 text; read them back and decode yourself. dump()is not supported (throws).withSession()returns the sameenv.DB.
The legacy env.DB.query(sql, params?) (pg-shaped { rows, rowCount, columns }) still works for existing apps. Both table engines are reachable through either API:
| Row store (default) | ENGINE = MergeTree | |
|---|---|---|
| Shape | Row-oriented, OLTP | Columnar, OLAP |
| Create | CREATE TABLE t (...) | CREATE TABLE t (...) ENGINE = MergeTree ORDER BY (cols) [PARTITION BY col] |
| Strengths | point reads/writes, joins, ACID BEGIN…COMMIT | scans, GROUP BY, data skipping |
env.S3 — R2 API
env.S3 is a Cloudflare R2-shaped client over an S3 bucket configured through the app's secrets. It is only bound when all of S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY are set (optional: S3_REGION, default us-east-1; S3_FORCE_PATH_STYLE, default true). Use env.S3.configured() to check at runtime. All signing is done host-side by the AWS SDK; credentials never reach the worker.
ts
interface S3Bucket {
configured(): boolean;
head(key: string): Promise<R2Object | null>;
get(key: string, options?: { range?: R2Range | Headers; onlyIf?: R2Conditional | Headers }): Promise<R2ObjectBody | R2Object | null>;
put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null,
options?: { httpMetadata?: R2HTTPMetadata | Headers; customMetadata?: Record<string, string>; onlyIf?: R2Conditional | Headers }): Promise<R2Object | null>;
delete(keys: string | string[]): Promise<void>;
list(options?: { prefix?: string; delimiter?: string; cursor?: string; startAfter?: string; limit?: number; include?: ("httpMetadata" | "customMetadata")[] }): Promise<R2Objects>;
// legacy forms:
list(prefix: string, maxKeys?: number): Promise<string[]>;
presignGet(key: string, ttlSeconds?: number): Promise<string>;
}getsupportsrange(offset/length/suffix, or aRangeheader) andonlyIfconditional reads (etagMatches/etagDoesNotMatch/uploadedBefore/uploadedAfter, or request headers). A failed condition returns theR2Objectmetadata with no body.putacceptshttpMetadata,customMetadataandonlyIf(If-Match/If-None-Match); a failed condition returnsnull.deletetakes one key or an array (batched in groups of 1000).listsupportsprefix,delimiter,cursor,startAfter,limitandinclude, returning{ objects, truncated, cursor, delimitedPrefixes }. Returned objects carrykey,size,etag,httpEtag,version,uploaded,httpMetadata,customMetadata, andwriteHttpMetadata(headers).- Legacy
list(prefix, maxKeys)returns a plainstring[]of keys, andpresignGet(key, ttl)mints a signed GET URL (default 1h, max 24h; pure signing, no network).
ts
const obj = await env.S3.get("music/track.mp3", { range: { offset: 0, length: 65536 } });
const url = await env.S3.presignGet("music/track.mp3", 3600); // stream directly to <audio>Limits: this is the S3 protocol only — point S3_ENDPOINT at an S3-compatible endpoint. Objects are capped at 32 MiB per get/put; there is no multipart API (createMultipartUpload throws — use presignGet for large objects). Egress is SSRF-guarded at the DNS level: the endpoint is resolved and refused if it points at any internal address (loopback, link-local incl. the 169.254.169.254 metadata IP, RFC-1918/ULA), so point it at a public endpoint.
env.<NAME> — email binding
An Owner/Admin binds an email connection (an org's SMTP server or provider API, configured in the console) to the app as env.<NAME> (e.g. env.EMAIL). The binding carries the connection and, optionally, a narrowed sender list and a recipient allow-list; developers may use an existing binding but not create one. The host builds the MIME message itself from the fields below — there is no raw MIME form.
ts
interface EmailBinding {
send(message: EmailSendMessage): Promise<{ messageId: string }>;
}
interface EmailSendMessage {
from: string; // must be a verified sender of the connection, and within the binding's senders
to: string | string[];
cc?: string | string[];
bcc?: string | string[];
replyTo?: string | string[];
subject: string; // ≤ 998 characters
text?: string; // text and/or html required
html?: string;
headers?: Record<string, string>; // custom X-* headers only
attachments?: { filename: string; content: string | ArrayBuffer | ArrayBufferView; contentType: string }[];
}ts
const { messageId } = await env.EMAIL.send({
from: "[email protected]",
to: ["[email protected]"],
subject: "Welcome",
text: "Hello",
html: "<p>Hello</p>",
});- Addressing.
to/cc/bcctake a string or array. The sender list, the binding's recipient allow-list and the recipient cap all apply to the union ofto,ccandbcc.frommust be a verified sender of the connection and within the binding'ssenders; aFromoutside the list is refused before it reaches the provider. The SMTP envelope sender is set host-side, never by the worker. - Headers may be set only with an
X-prefix;DKIM-Signature,Return-Path,Sender,ReceivedandMessage-IDare generated by the host or provider and cannot be set. - Attachments take
contentas a string,ArrayBufferor typed array;contentTypemust be a validtype/subtype. The host encodes the body and attachments itself. - Limits: at most 50 recipients across
to/cc/bcc, total message ≤ 10 MiB (attachments included), subject ≤ 998 characters, ≤ 20 custom headers, ≤ 20 attachments. - Errors:
send()rejects with anErrorwhosecodeis one ofEMAIL_REJECTED(the message or its sender was refused),EMAIL_RATE_LIMITED(the org/app send quota is exhausted) orEMAIL_UNAVAILABLE(the connection or provider could not be reached). The message is sanitized — raw provider responses and credentials are never surfaced.
ts
try {
await env.EMAIL.send({ from, to, subject, text });
} catch (e) {
if ((e as { code?: string }).code === "EMAIL_RATE_LIMITED") return json({ error: "try later" }, { status: 429 });
throw e;
}env.queue — send to a queue
env.queue is a fixed object for sending messages to any queue in the app's own project and environment, addressed by the queue's slug — no binding to create. The host resolves the slug within the app's scope, so a queue in another project, environment or organisation is never visible; an unknown slug throws queue not found: <slug> (surfaced as the queue slug must be a non-empty string for an empty value).
ts
interface QueueSender {
send(slug: string, body: unknown, opts?: SendOptions): Promise<{ id: string }>;
sendBatch(slug: string, messages: ({ body: unknown } & SendOptions)[]): Promise<{ ids: string[] }>;
}
interface SendOptions {
contentType?: "json" | "text" | "bytes"; // default json; bytes for ArrayBuffer/typed arrays
delaySeconds?: number; // 0–43200
attributes?: Record<string, unknown>;
dedupId?: string;
}ts
await env.queue.send("orders", { id: 1 }, { delaySeconds: 30 });
await env.queue.sendBatch("orders", [{ body: { id: 1 } }, { body: { id: 2 }, delaySeconds: 5 }]);- Body encoding follows
contentType:json(JSON-serialized; a non-serializable body throws),text(the body stringified), orbytes(anArrayBuffer/typed array, base64-encoded on the wire;bytesis the default when the body is binary). - Backpressure, not quotas. There are no per-org or per-app send quotas; a send is bounded by the queue's
maxDepth/maxBytesand is refused withqueue fullwhen the queue is at capacity, with nothing partially written. - A send is refused while the sending app or its organisation is suspended.
env.kv(slug) — KV namespaces
A KV namespace is created per project and environment in the console (KV) or the management API (POST /v1/orgs/{org}/projects/{project}/envs/{env}/kv {slug}, admin role). env.kv(slug) returns Cloudflare's KVNamespace for a namespace in the app's own project and environment; any other slug throws KV namespace not found: <slug>.
ts
const kv = env.kv("sessions");
await kv.put("user:1", JSON.stringify({ name: "Ada" }), { expirationTtl: 3600, metadata: { v: 1 } });
const user = await kv.get("user:1", "json"); // "text" (default), "json", "arrayBuffer", "stream"
const { value, metadata } = await kv.getWithMetadata("user:1");
const page = await kv.list({ prefix: "user:", limit: 100, cursor }); // { keys, list_complete, cursor? }
await kv.delete("user:1");- Limits are Cloudflare's: key ≤ 512 bytes, value ≤ 25 MiB, metadata ≤ 1 KiB serialized,
expirationTtl≥ 60 s,listlimit ≤ 1000. Errors use Cloudflare's messages (KV PUT failed: 414 …).cacheTtlis accepted and ignored. - Consistency is read-your-writes on the node, stronger than Cloudflare's eventual consistency.
- Expired keys disappear on read and are removed by a background sweep about once a minute.
- Data API:
GET /v1/data/{org}/{project}/{env}/kv/{slug}/keys?prefix=&cursor=&limit=, andGET/PUT/DELETE …/keys/{key}(URL-encode the key).PUTtakes the raw value as the body,?expiration=or?expiration_ttl=, and metadata as anx-kv-metadataJSON header;GETreturns the raw bytes withx-kv-expiration/x-kv-metadataheaders. Viewers read, developers write.
env.AI — Workers AI-compatible text generation
env.AI uses the same call as Cloudflare Workers AI: env.AI.run(model, inputs, options). It is an AI binding, declared like email and storage bindings:
yaml
ai: { AI: { connection: platform } } # the instance's shared AI, or an org AI connection's nameplatform: the instance holds the keys, and the app is at zero quota until an instance owner grants it (or its project) under Instance → Limits & quotas → AI.- An org connection (Connections → AI): the org's own OpenAI or Anthropic key and model aliases. There is no platform quota, because the provider bills the org.
The worker never sees a key. Each binding name becomes its own object (env.AI, env.WRITER, …).
ts
const out = await env.AI.run("@totis/text-smart", {
messages: [
{ role: "system", content: "You write short product blurbs." },
{ role: "user", content: "A solar lantern" },
],
max_tokens: 400,
response_format: { type: "json_object" }, // or { type: "json_schema", json_schema: {...} }
});
out.response; // a string, or an object in the JSON modes
out.usage; // { prompt_tokens, completion_tokens, total_tokens }Models.
- On
platform: a platform alias (@totis/text-default,@totis/text-fastor@totis/text-smart) or a provider id (openai/<model>,anthropic/<model>). A Cloudflare@cf/...id works only if the admin maps it to an alias. The grant lists which models the app may call. - On an org connection: one of its aliases, or a model of its provider.
Inputs. Pass exactly one of prompt or messages. The other inputs are max_tokens, temperature (0–2), top_p, seed, stop (up to 4), tools (OpenAI function format) and response_format. A tool call comes back in tool_calls as { name, arguments }; the platform never runs it.
Not supported: stream: true and returnRawResponse. Cloudflare's gateway options are ignored. Of the options, only timeoutMs (up to 300 s, 60 s by default) is used.
Other calls. env.AI.models() lists the models this app may call. env.AI.usage() returns { enabled, tokensToday, tokensPerDay, tokensLeft, requestsToday } on platform, and { enabled: true, platform: false } on an org connection.
Errors. A failed call throws an Error with code set, and the message starts with the same code:
| Code | Meaning |
|---|---|
AiNotEnabled | AI is not granted to this app. |
AiQuotaExceeded | A quota is used up; retryAfter gives the seconds to wait. |
AiModelNotAllowed | The model is unknown or not in the grant. |
AiInvalidInput | The inputs are malformed or too large (1 MB request limit). |
AiUpstreamError | The provider failed after retries. |
AiTimeout | The call ran out of time. |
Long generations belong in a job app (env.jobs), where the wall-clock limit is higher than a request's.
Analytics Engine: analytics_engine_datasets
A manifest entry "analytics_engine_datasets": [{ "binding": "HITS", "dataset": "pages" }] gives env.HITS.writeDataPoint({ blobs, doubles, indexes }). dataset defaults to the binding name.
- Limits are Cloudflare's: ≤ 20 blobs (≤ 16 KiB in total), ≤ 20 doubles, ≤ 1 index of ≤ 96 bytes, 250 data points per invocation. Writes are fire-and-forget and are dropped if the node's write queue is full.
- Querying: every org has one read-only analytics database, reached through the data API on the reserved slug
analytics(any project and environment the token can see):POST /v1/data/{org}/{project}/{env}/databases/analytics/query {"sql": "SELECT blob1, SUM(double1) FROM analytics WHERE dataset = 'pages' GROUP BY blob1"}. Columns:`timestamp`(quote it),dataset,index1,blob1…blob20,double1…double20,_sample_interval(always 1) and_day. Viewers can query; nothing can write. Data is kept for 90 days.
Image resizing: cf.image and env.IMAGES
fetch(url, { cf: { image: { width, height, fit, quality, format } } }) resizes the image it fetches. A URL on the app's own host reads the app's published site; any other URL is an ordinary fetch(), with the same egress rules. With "images": { "binding": "IMAGES" } in the manifest, env.IMAGES.input(bytes).transform({ width, height, fit }).output({ format, quality }) resizes bytes the worker already has, and env.IMAGES.info(bytes) reads the dimensions.
fit:scale-down(default),contain,cover,crop,pad;quality1–100 (default 85).format:jpeg,pngorauto. No WebP or AVIF output: those (andauto) produce JPEG for JPEG sources and PNG otherwise. JPEG, PNG, GIF and WebP sources are read.- Source ≤ 20 MiB and ≤ 50 megapixels (checked from the header), output ≤ 10 000 px a side. Other options are ignored with one log warning per app; a busy node answers
503.
Consuming a queue: the queue() handler
An app is set as a queue's consumer in the console (one consumer app per queue). The host's dispatcher claims a batch and delivers it to the app's queue(batch, env, ctx) handler — the second entry point of the default export, alongside fetch:
ts
export default {
async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext): Promise<void> {
for (const msg of batch.messages) {
try {
await handle(msg.body);
msg.ack();
} catch {
msg.retry({ delaySeconds: 30 });
}
}
},
fetch: (req, env, ctx) => router.handle(req, env, ctx),
};ts
interface MessageBatch {
queue: string; // the queue's slug
messages: Message[];
ackAll(): void;
retryAll(opts?: { delaySeconds?: number }): void;
}
interface Message {
id: string;
timestamp: Date; // when the message was enqueued
attempts: number; // delivery attempt (1 on first delivery)
contentType: "json" | "text" | "bytes";
attributes: Record<string, unknown>;
body: unknown; // decoded per contentType (bytes → Uint8Array)
ack(): void;
retry(opts?: { delaySeconds?: number }): void;
}- Settlement. A message you neither
ack()norretry()is settled by the handler's outcome: everything unsettled is acked whenqueue()resolves, and retried when it throws. Explicitack()/retry()win and the first call for a message sticks. A handler that throws therefore retries the whole batch. - At-least-once. A retried or unacked message is redelivered with
attempts + 1; past the queue'smaxRetriesit moves to the queue's failed area (see the queues guide). Delivery is push-only: a queue with a consumer app cannot also be pulled over the data API. - An app with no
queue()handler retries the batch (nothing is consumed). A suspended consumer app or organisation is delivered nothing.
Job apps: the run() handler
An app whose manifest says "kind": "job" has no public surface: its host answers 404 and fetch is never called. It runs on its schedule (up to 3 crons, UTC) or when a developer starts it from the console, totis jobs start or POST /v1/apps/:id/runs.
ts
export default {
async run(ctx, env) {
for (const day of daysToRoll(ctx.params)) {
if (ctx.signal.aborted) return; // stopped by a user or by suspension
await rollUp(env, day);
ctx.progress(`${day} done`); // last line shown in the console, at most 200 chars
}
},
};jsonc
{ "name": "nightly-rollup", "kind": "job", "schedule": ["30 2 * * *"] }ctx.runId,ctx.trigger("schedule","manual"or"webhook"),ctx.scheduledTime(schedule runs),ctx.params(the JSON object given at a manual start; for a webhook, the JSON body, a form body's fields, or{ raw };{}otherwise).- A webhook run also has
ctx.webhook = { checks, deliveryId?, event? }:checkslists what the request passed ("url", plus"basic"and"hmac"when the hook has them), anddeliveryIdandeventare the values of the hook's configured delivery-id and event headers. No other request header reaches the job. See Job webhooks. - Returning ends the run
succeeded, throwing ends itfailed; there are no retries. - One run at a time: a trigger that arrives while a run is going is recorded as
skipped. - A run may take 15 minutes of wall time (an instance owner can set 1 to 60 per app); past that it is
timed-out. A stop setsctx.signaland leaves the run 30 s to return, then it is ended (even mid-loop) and markedstopped. ctx.signal.abortedandctx.progress()are where a run learns of a stop, so it can stop cleanly. A host call in progress when the grace ends (afetch, a query) finishes first. On the Hyperlight runtime a stop is not forced: a loop that checks neither runs on to its time limit.- Params, the error and the progress line are stored with the app's secret values redacted.
- A job is created as one: New job in the console,
totis jobs create,totis deployof atotis.yamlapp withkind: job, orPOST …/envs/:env/appswith{ "slug", "kind": "job" }. The kind can't change afterwards: a deploy whose manifest names the other kind is refused, and a manifest withoutkindkeeps it. - A job gets the same
envas an app (databases, queues, KV, storage, email, Analytics Engine, images, outboundfetch, secrets), provisioned by the same manifest keys andtotis deploy. It can send to queues but can't be a queue consumer: a manifest withqueues.consumersis refused for a job, as is setting a job as a queue's consumer. scheduleis read only for job apps and replacestriggers.crons; entries are cron strings or{ "cron", "name", "description" }objects, as intriggers.crons.
env.SECRETS and env.NAME
Secrets set in the console (at org, project or app scope), encrypted at rest (AES-GCM, in the instance vault under the KEK). They are injected both as env.SECRETS.NAME and, for convenience, flattened directly onto env.NAME (the flattened form does not overwrite a real binding of the same name). Values never appear in a response unless the worker returns them, and the API never reveals them.
ts
const key = env.SECRETS.API_KEY; // or env.API_KEYAlso injected per request: env.NOW (server epoch millis) and env.RAND (32 random bytes as hex).
Secrets are set, listed (names only) and deleted in the console at org, project or app level; an org secret reaches an app only through an explicit link.
crypto / crypto.subtle
crypto.getRandomValues(arr) (integer typed arrays, ≤ 65536 bytes or it throws QuotaExceededError), crypto.randomUUID(), and a substantial crypto.subtle. Every operation runs host-side in the JDK provider.
| Operation | Algorithms |
|---|---|
digest | SHA-1, SHA-256, SHA-384, SHA-512, MD5 |
sign / verify | HMAC, RSASSA-PKCS1-v1_5, RSA-PSS, ECDSA (raw r‖s / P1363), Ed25519 |
encrypt / decrypt | AES-GCM, AES-CBC, AES-CTR, AES-KW, RSA-OAEP |
deriveBits / deriveKey | PBKDF2, HKDF, ECDH, X25519 |
generateKey / importKey / exportKey | all of the above; formats raw, spki, pkcs8, jwk |
wrapKey / unwrapKey | via export+encrypt / decrypt+import |
Curves: P-256, P-384, P-521 (plus Ed25519/X25519). crypto.subtle.timingSafeEqual(a, b) is available.
Bounds (enforced host-side because these run on a thread the sandbox timeout cannot interrupt — an unbounded prime search or KDF would starve the node):
- RSA
modulusLength∈ {2048, 3072, 4096};publicExponent∈ {3, 65537}. - PBKDF2 iterations ≤ 100,000.
- HKDF output ≤ 255 × hashLen; derived bits ≤ 8192.
- HMAC generated key length ≤ 4096 bits.
Key custody: a key "handle" is the key material, held by the worker — the host stores nothing. A worker only ever sees keys it imported or generated, so extractable: false is advisory: exportKey honours it, but the runtime provides no stronger isolation than that.
request.cf
Filled by the host on every request:
| Field | Source |
|---|---|
colo | The node's name, node.name in the server config (local when unset). |
httpProtocol | HTTP/1.1, … |
tlsVersion, tlsCipher | Only when TLS ends at this server; absent behind a TLS-terminating proxy and on plain HTTP. |
clientTcpRtt | Always 0. |
country, isEUCountry, continent, city, region, regionCode, postalCode, metroCode, latitude, longitude, timezone | From the client IP, when the operator configures a MaxMind-format city database (geo.database). Absent otherwise. latitude/longitude are strings, as on Cloudflare. |
asn, asOrganization | From an optional ASN database (geo.asnDatabase). |
botManagement and the TLS client-auth fields are never set. request.cf is computed per request, handed only to the worker, and never logged.
fetch
Outbound fetch(input, init?) with the standard Request/Response surface. It is off by default: an instance owner allows it for the whole instance or per organisation in instance settings (an organisation's own setting wins over the instance default). A disabled fetch rejects with a message saying so.
- Redirects: honours
redirect: "follow"(default),"manual", and"error"; up to 20 hops. On a cross-origin redirect,authorization,cookieandproxy-authorizationare stripped (as in browsers/Cloudflare). A 303 (and a 301/302 after a POST) is rewritten to GET with the body and body headers dropped. - SSRF guard: internal addresses are refused at the DNS level — the HTTP client's resolver vets every address actually connected to, and the guard re-runs on each redirect hop, closing the DNS-rebinding window. Loopback, link-local (incl.
169.254.169.254), and RFC-1918/ULA are blocked regardless of the opt-in flag. - Private targets: an instance owner can allow exact
host:portpairs on a private address withfetchentries in the private address allow-list (Instance → Limits), as for storage and SMTP. Metadata and link-local addresses stay refused. - Response cap: the response body is capped at 8 MiB (exceeding it fails the fetch). Connect/read timeouts apply.
ts
const r = await fetch("https://api.example.com/rates");
const data = await r.json();Client IP and forwarding headers
request.headers.get("cf-connecting-ip") is the server's own answer for the client IP — any client-sent CF-Connecting-IP is dropped before the worker sees the request, so it cannot be forged. The server resolves it from the socket peer, trusting X-Forwarded-For / CF-Connecting-IP (and X-Forwarded-Proto for the request scheme) only when the immediate peer is a local/private address — i.e. a reverse proxy in front. A direct public client cannot spoof its IP or scheme.
Cookies & sessions
Read cookies from request.headers.get("cookie") and set one with a Set-Cookie response header (multiple are supported). The host scopes each worker cookie to the app — it strips any Domain, forces Path to /apps/<app>/ (or / on a dedicated custom domain), and always adds HttpOnly; Secure, plus a SameSite (default Lax) — so a worker can't set cookies for the console or another app. Only Max-Age/Expires/SameSite pass through; a malformed or control-character cookie is dropped.
ts
return json({ ok: true }, { headers: { "set-cookie": `sid=${token}; Max-Age=2592000; SameSite=Lax` } });Worker response headers are also filtered: hop-by-hop, Set-Cookie (handled separately), CORS (access-control-*), Refresh, Content-Security-Policy and X-Frame-Options are not forwarded from the worker; the host sets X-Frame-Options: DENY, X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer itself.
Location is forwarded, so Response.redirect(...) works, including to other sites (e.g. OAuth). For an app served under /apps/<app>/, a root-relative or same-origin target is mapped back under the app's prefix, so Response.redirect(new URL("/login", request.url)) lands on /apps/<app>/login; off-site targets and targets already under the prefix are left as-is. Apps on a custom domain are not rewritten.
env.CACHE — in-memory TTL cache
A process-global key/value store with a per-entry TTL, for ephemeral coordination state (presence, live ticks, rate counters) too hot to round-trip through env.DB.
ts
interface Cache {
get(key: string): string | null; // null if missing or expired
set(key: string, value: string, ttlSeconds?: number): void; // default 30s, max 1h
del(key: string): void;
getPrefix(prefix: string): { key: string; value: string }[]; // all live entries under a prefix
}Values are opaque strings (JSON-encode yourself); entries are namespaced per app; storage is per node (not cluster-shared) and lost on restart — never store anything you can't rebuild.
caches — Cache API
Cloudflare's Cache API: caches.default, or await caches.open(name) for a named cache, each a Cache with match(request, {ignoreMethod}), put(request, response) and delete(request, {ignoreMethod}). A request may be a Request or a URL string.
- Only
GETis cached:putof another method throws aTypeError, as do a206response andVary: *.matchanddeleteof another method miss unlessignoreMethodis set. puthonoursCache-Control(s-maxage, thenmax-age, thenExpires);no-store,no-cache,private,max-age=0and aSet-Cookieheader mean "not stored". A response with none of them stays until evicted.putconsumes the response body, so cacheresponse.clone().- The key is the full URL plus the request headers the response's
Varynames.deleteremoves every variant of the URL.matchon aRangerequest returnsundefined; a hit carriescf-cache-status: HIT, and anIf-None-Matchthat matches the storedETaggets a304. - Storage is per app and per node (not replicated, like Cloudflare's per-colo cache), LRU-evicted under 64 MiB per app and 512 MiB per node by default (instance settings
cacheAppBytesandcacheNodeBytes, applied on the next start). Bodies over 10 MiB are not stored. A redeploy keeps the cache; deleting the app drops it; a restart empties it.
env.ASSETS — the app's static site
With "assets": { "binding": "ASSETS" } in the manifest, env.ASSETS.fetch(requestOrUrl) serves the app's own uploaded site (only the URL's path is used; another app's site is unreachable). It answers like the site router: content type, ETag / If-None-Match → 304, html_handling and not_found_handling (below), 404 for a miss and 405 for a method other than GET/HEAD.
Routing: a GET/HEAD goes to the site first and falls through to the worker on a miss; "run_worker_first": true sends every request to the worker first. Manifest settings:
html_handling:auto-trailing-slash(default:/aboutservesabout.html,/about.html307-redirects to/about,/docsredirects to/docs/whendocs/index.htmlexists),force-trailing-slash,drop-trailing-slashornone(exact paths only).not_found_handling:single-page-application(a miss serves/index.htmlwith 200; with a worker, only navigations —Sec-Fetch-Mode: navigate— get it, other misses reach the worker),404-page(the nearest404.htmlwith status 404, for apps without a worker or viaenv.ASSETS) ornone. Unset, an app without a worker still answers a miss withindex.html.
Auth (jwt + authed)
A built-in HS256 JWT helper so apps don't reinvent login/session auth. It is pure JS (it does not use crypto.subtle), standard-compliant, and signs against a per-app secret — set JWT_SECRET once in the console's app secrets.
ts
export const jwt: {
sign(payload: JwtClaims, secret: string, opts?: { expiresInSeconds?: number }): Promise<string>;
verify(token: string, secret: string): Promise<JwtClaims>; // throws on bad signature / malformed / expired
};authed(handler) wraps a route so it runs only for a valid Authorization: Bearer <jwt> — the verified claims are passed as the 5th argument; anything else returns 401. The secret defaults to env.SECRETS.JWT_SECRET (override with opts.secret). Signatures are compared in constant time.
ts
router.post("/login", async (req, env) => {
const { user, pass } = (await req.json()) as { user: string; pass: string };
if (!(await checkPassword(env, user, pass))) return json({ error: "bad credentials" }, { status: 401 });
return json({ token: await jwt.sign({ sub: user }, env.SECRETS.JWT_SECRET, { expiresInSeconds: 3600 }) });
});
router.get("/me", authed((_req, _env, _ctx, _params, claims) => json({ you: claims.sub })));Scope today: HS256 (symmetric) only — your app both signs and verifies. Verifying third-party IdP tokens (RS256 / JWKS) and refresh/revocation are not built in. For asymmetric work use crypto.subtle directly.
Router
Optional sugar for path routing. Exact segments plus :name captures; first match wins; unmatched → 404. It reads the route from new URL(request.url).pathname.
ts
const router = new Router<Env>();
router.get("/category/:name", async (_req, env, _ctx, params) => json({ category: params.name }));
export default {
fetch: (req, env, ctx) => router.handle(req, env, ctx),
} satisfies ExportedHandler<Env>;| Method | |
|---|---|
get / post / put / delete(pattern, handler) | Register for that HTTP method. |
all(pattern, handler) | Register for any method. |
handle(request, env, ctx) | Match and dispatch; returns the handler's Response or a 404. |
Route handlers receive (request, env, ctx, params) — params holds the :name captures.
Deploy & invoke
Apps are written in TypeScript or JavaScript — the two supported languages. Both are bundled server-side: types are stripped, import ... from "./tinys3-worker" is resolved to the inlined SDK, and an ES-module export default { fetch } becomes the handler. Plain JavaScript can therefore be an ES module or an already-bundled script that sets globalThis.__worker. Deploy through the platform — the totis CLI or the console — which calls POST /v1/apps/{appId}/deploy:
json
{ "source": "<worker.ts or worker.js source>", "lang": "ts", "public": true }| Field | |
|---|---|
source | The worker source. |
lang | ts or js. Both are bundled server-side. |
public | true to allow anonymous HTTP; omit to keep the app's current visibility. Code is private either way. |
A manifest field ({ "name": "totis.json" | "wrangler.toml" | "wrangler.jsonc" | "wrangler.json", "text": "…" }) adds Cloudflare-style named bindings; see Named bindings.
See the Deploy API for the CLI and the full endpoint set. Invoke over HTTP — the worker routes on the path after the app's label:
GET/POST <label>.<apps base>/<path> (or /apps/<label>/<path>) → export default.fetch(request, env, ctx)Named bindings: totis.json / wrangler.toml
A deploy may carry a manifest. Only these keys are read; anything else comes back as a deploy warning:
jsonc
{ "queues": { "producers": [{ "binding": "MY_QUEUE", "queue": "orders" }],
"consumers": [{ "queue": "orders", "max_batch_size": 10 }] },
"d1_databases": [{ "binding": "DB2", "database_name": "shop" }],
"kv_namespaces": [{ "binding": "MY_KV", "id": "sessions" }],
"triggers": { "crons": ["*/5 * * * *"] },
"assets": { "binding": "ASSETS", "not_found_handling": "single-page-application" } }- Aliases only.
env.MY_QUEUE.send(body)isenv.queue.send("orders", body)andenv.DB2isenv.db("shop"): the slug must name a queue or database in the app's own project and environment, or the deploy fails with an error naming it. queues.consumersmakes this app the queue's consumer (max_batch_size,max_batch_timeoutin seconds andmax_concurrencyare read). It needs the admin role, as in the console; without it the deploy is refused with 403. Removing it from the manifest later does not unset the consumer.triggers.cronslists up to 3 schedules, each a cron string or{ "cron": "*/5 * * * *", "name": "refresh", "description": "…" }(name up to 64 chars, description up to 280).scheduled(controller)sees the name ascontroller.name; a plain string is named after its own cron text. An absent key keeps the schedules,[]clears them.kv_namespacesbindsenv.MY_KVtoenv.kv("sessions");idis the namespace's slug.analytics_engine_datasetsandimagesbind Analytics Engine and image resizing.assetsbindsenv.ASSETS(binding) and setshtml_handling,not_found_handlingandrun_worker_first(a boolean; route lists are refused).directoryis accepted and ignored: the site is uploaded separately.secretslists the secret names the app needs (["SESSION_HMAC_KEY"]), never values; any other shape is refused. A deploy fails while a listed name is not set as an app or environment secret or a linked org secret.totis deploycreates the missing ones fromtotis.yaml.name,org,projectandenvare ignored by the server without a warning (thetotisCLI takes its targeting fromtotis.yamlinstead).- Binding names may not shadow
DB,db,queue,kv,S3,CACHE,SECRETS,PLATFORM,SYSDB,NOWorRAND, and a name may be used once. - A deploy without a manifest keeps the last one; deploy
{}astotis.jsonto clear it. Thetotis deployCLI builds the manifest fromtotis.yaml; a one-offtotis deploy worker.tssends the first ofwrangler.toml,wrangler.jsonc,wrangler.jsonbeside the source file (or--manifest <file>).GET /v1/apps/{appId}/manifestreturns the stored one.
Limits
Same confinement as the WASM sandbox (SandboxLimits): instruction fuel, a wall-clock deadline that excludes host-call time, a cumulative host-call-time budget, output-size and memory caps. A handler that exceeds them is aborted and the request gets a 500 (or a 503 when the sandbox is saturated, 507 when the app's disk quota is exceeded, 429 when rate-limited).
WebSockets
A worker accepts a WebSocket the Cloudflare way:
js
export default {
async fetch(request) {
if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
const [client, server] = Object.values(new WebSocketPair());
server.accept();
server.addEventListener("message", (event) => {
const n = (server.deserializeAttachment() || { n: 0 }).n + 1;
server.serializeAttachment({ n });
server.send(`#${n}: ${event.data}`);
});
return new Response(null, { status: 101, webSocket: client });
},
};The server holds the connection, not the worker, so every incoming message is a new, short worker run. There are two ways that run reaches your code.
Handler mode (recommended). Export webSocketMessage (and optionally webSocketClose, webSocketError) next to fetch, as on Cloudflare. A message then calls the export directly and fetch() is not run again:
js
export default {
async fetch(request, env) {
if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
// Runs once per connection: authenticate, log the join, etc.
const [client, server] = Object.values(new WebSocketPair());
server.accept();
server.serializeAttachment({ n: 0, user: "..." });
return new Response(null, { status: 101, webSocket: client });
},
async webSocketMessage(ws, message, env, ctx) {
const s = ws.deserializeAttachment();
ws.serializeAttachment({ ...s, n: s.n + 1 });
ws.send(`#${s.n + 1}: ${message}`);
},
async webSocketClose(ws, code, reason, wasClean, env, ctx) {},
async webSocketError(ws, error, env, ctx) {},
};ws supports send, close, serializeAttachment and deserializeAttachment; message is a string or an ArrayBuffer. webSocketError is called when the connection drops without a close frame (then webSocketClose follows with 1006).
Replay mode (no webSocket* exports, e.g. addEventListener as in the example above). The upgrade request is replayed through fetch() so the listeners added at accept() exist again, and then the message (or close/error) event is delivered to them.
Warning: in replay mode
fetch()runs again for every message. Database writes, queue sends, outbound fetches and any other side effect infetch()repeat per frame; only what the replayedfetch()sends, closes or attaches on the socket is dropped. Put side effects inside the listeners, or use handler mode. Authentication is pinned at the upgrade: the replay carries the original request's headers and cookies, so a session revoked after the upgrade is not re-checked unless your code does so.
Both modes:
- Module-level variables do not survive between messages. Keep per-connection state in
serializeAttachment(value)/deserializeAttachment()(JSON, at most 2 KiB). send(string | ArrayBuffer),close(code?, reason?),readyStateand themessage,closeanderrorevents are supported. Messages are capped at 1 MiB (an incoming text frame over the cap closes the socket with 1009); the server answers pings itself.- Every message run counts against the app's rate limit and shows up in request telemetry as method
WS. Close codes the server uses:1008— the connection sent more than 100 messages/s, or the app/organisation was suspended.1013— the app's rate limit, the organisation's message ceiling (2,000/s), or a buffer budget was exceeded (4 MiB of unprocessed messages per connection, 64 MiB per organisation, 256 MiB per node), or the client reads too slowly (more than 4 MiB of unread outgoing data).1011— the worker threw, or an attachment was over 2 KiB.
- Idle sockets close after 10 minutes (1001). An organisation may hold 1,000 open sockets and a node 10,000; past that the upgrade gets
503(checked before your worker runs). - Not supported:
new WebSocket(url)andfetch()withUpgrade: websocket(outbound sockets), broadcasting to other connections, and in-memory state across messages (Durable Objects).
Cross-site WebSocket hijacking (CSWSH)
Browsers attach cookies to a WebSocket handshake from any site and the same-origin policy does not apply. If your socket trusts a session cookie, check request.headers.get("Origin") in fetch() before accepting, or let the server do it with the manifest:
json
{ "websocket": { "allowed_origins": ["https://chat.example.com"] } }With allowed_origins set, an upgrade whose Origin is missing or not listed gets 403 before the worker runs. Without it (the default, as on Cloudflare) every origin reaches your code.
Not supported yet
Present in Cloudflare Workers, absent here — do not reach for them:
- Durable Objects and outbound WebSockets. (Queues are supported — see
env.queueand thequeue()handler.) node:compatibility beyondnode:buffer,node:events,node:streamandnode:async_hooks. Importing any othernode:orcloudflare:module (other thancloudflare:workers) fails the deploy, even when the import is never used; so does a literalimport("...")of one. A dynamicimport()with a computed specifier deploys and is resolved when it runs: a supported built-in loads, anything else rejects with "<module>is not supported on TotisWorkers". A computed specifier cannot reach your own files or packages.- True streaming. The stream classes exist, but bodies are buffered in memory (bounded by the size caps), not streamed incrementally.