toiljs 0.0.112 → 0.0.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,7 +59,7 @@ export const TOIL_DOCS = {
59
59
  "realtime/channels.md": "# Channels (`useChannel`)\n\n`useChannel` is the client-side React hook for realtime. It opens a live connection from the browser to a server `@stream` box, tracks whether it is connected, collects the messages that arrive, and gives you a `send` function. It reconnects on its own if the connection drops.\n\n## What a channel is\n\nA **channel** is simply an open, two-way connection between one browser and the server, viewed from the client's side. On the server that connection is handled by a [`@stream`](./streams.md) box. On the client you hold the other end with `useChannel` (a React hook) or `connectChannel` (a plain function, no React).\n\nYou have already met the two-piece model in the [realtime overview](./README.md): a `@stream` on the server, a channel on the client. This page is the client half.\n\n## The `useChannel` hook\n\n`useChannel` is available on the global `Toil` object in your route files, so no import is needed:\n\n```tsx\nexport default function Ping() {\n const chan = Toil.useChannel({ path: '/echo' });\n\n return (\n <main>\n <p>Status: <strong>{chan.connected ? 'connected' : 'offline'}</strong></p>\n <button type=\"button\" onClick={() => chan.send('ping')}>Send ping</button>\n <ul>\n {chan.messages.map((m, i) => (\n <li key={i}><code>{typeof m === 'string' ? m : '(binary frame)'}</code></li>\n ))}\n </ul>\n </main>\n );\n}\n```\n\n### What it returns\n\n`useChannel(options?)` returns an object with three things:\n\n| Field | Type | What it is |\n| ----------- | -------------------------- | ---------------------------------------------------------------------- |\n| `connected` | `boolean` | `true` while the socket is open. Re-renders when it changes. |\n| `messages` | `(string \\| ArrayBuffer)[]`| every frame received so far, in order. A new frame re-renders. |\n| `send` | `(data) => void` | send one frame to the server (a no-op until the socket is open). |\n\nA frame is either a **string** (text) or an **`ArrayBuffer`** (binary). Send accepts a string or binary data. To send or read structured data, encode and decode it yourself:\n\n```ts\nchan.send(new TextEncoder().encode('hello')); // send bytes\nconst text = (m: string | ArrayBuffer) =>\n typeof m === 'string' ? m : new TextDecoder().decode(m); // read either kind as text\n```\n\n### Options\n\n```ts\nToil.useChannel({\n path: '/echo', // which @stream route to connect to. Default: '/_toil'\n url: 'wss://...', // full ws(s):// override (wins over path). Usually omit this.\n reconnect: true, // auto-reconnect after an unexpected close. Default: true\n reconnectDelay: 1000 // ms to wait before each reconnect attempt. Default: 1000\n});\n```\n\nThe most important option is **`path`**: it points the channel at a specific `@stream` route. `{ path: '/echo' }` connects to a stream mounted with `@stream('echo')`. If you omit `path`, it connects to the default `/_toil` channel.\n\nYou do not build the `ws://` or `wss://` URL yourself. The hook derives it from the current page (a page served over `https` uses `wss`, otherwise `ws`), and on the production edge the transport is upgraded to WebTransport for you. Your code stays the same.\n\n### Lifecycle and cleanup\n\n`useChannel` connects when the component mounts and closes when it unmounts, so a channel lives exactly as long as the page that uses it is on screen. React's rules apply: call it at the top level of your component, not inside a loop or condition.\n\n### `connectChannel`: the non-React version\n\nIf you need a channel outside a React component (in a plain module, a store, an event handler), use `connectChannel`. It takes a callback for each incoming frame and returns a handle:\n\n```ts\nimport { connectChannel } from 'toiljs/client';\n\nconst chan = connectChannel(\n (frame) => console.log('got', frame),\n { path: '/echo' }\n);\nchan.send('hello');\nchan.close(); // stop and stop reconnecting\n```\n\n`useChannel` is `connectChannel` wrapped for React (it manages the `connected` / `messages` state for you).\n\n## The typed client: `Server.Stream`\n\n`useChannel` deals in raw frames. For a typed experience, toiljs also generates a client per `@stream` class at `Server.Stream.<ClassName>`, described in full on the [Streams](./streams.md#reaching-a-stream-from-the-browser) page. Use whichever fits: `useChannel` for a quick reactive hook, `Server.Stream.<Name>.connect()` for typed messages and explicit `onMessage` / `onClose` callbacks. Both open the same kind of connection to the same box.\n\n## A full chat-style example\n\nHere is a chat UI wired end to end: a React page that sends what you type and shows every reply, plus the server box it talks to.\n\n### Client (`client/routes/chat.tsx`)\n\n```tsx\nimport { useState } from 'react';\n\nexport default function Chat() {\n const chan = Toil.useChannel({ path: '/room' });\n const [draft, setDraft] = useState('');\n\n const asText = (m: string | ArrayBuffer): string =>\n typeof m === 'string' ? m : new TextDecoder().decode(m);\n\n function submit(): void {\n if (draft.length === 0) return;\n chan.send(draft); // one send() becomes one @message on the server\n setDraft('');\n }\n\n return (\n <main>\n <h1>Chat</h1>\n <p>Status: <strong>{chan.connected ? 'connected' : 'offline'}</strong></p>\n <ul>\n {chan.messages.map((m, i) => <li key={i}>{asText(m)}</li>)}\n </ul>\n <input\n value={draft}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}\n placeholder=\"Say something\"\n />\n <button type=\"button\" onClick={submit}>Send</button>\n </main>\n );\n}\n```\n\n### Server (`server/streams/Room.ts`)\n\n```ts\n@stream('room')\nclass Room {\n private seen: i32 = 0;\n\n @connect\n onConnect(): void {\n this.seen = 0;\n }\n\n @message\n onMessage(packet: StreamPacket): StreamOutbound {\n this.seen = this.seen + 1;\n const text = new TextDecoder().decode(packet.bytes());\n const reply = '#' + this.seen.toString() + ': ' + text;\n return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(reply)));\n }\n}\n```\n\nDo not forget to import the stream in `server/main.stream.ts` (see [Streams](./streams.md#the-mainstreamts-file-a-separate-tier)):\n\n```ts\nimport './streams/Room';\n```\n\nRun `toiljs dev`, open the page, and every line you send comes back numbered, live.\n\n### An honest limitation: this echoes, it does not broadcast\n\nRead the example carefully. Each browser has its **own** `Room` box, and that box replies only to **its own** connection. So in this version, two people in \"the same room\" do **not** see each other's messages: each just sees their own, echoed back. That is enough for a private assistant, a progress feed, or a single-player game, but it is **not** a shared chat room where one message reaches everyone.\n\nTo send one message to **many** connected users (true broadcast, the heart of a group chat or a live feed), you need the server to **fan out** a message to every subscriber. That is what `@channel` is for.\n\n## `@channel`: server broadcast (not yet available)\n\n> **Status: planned, not live in the current runtime.** A `@stream` class that declares a `@channel` hook is **rejected** by the edge today (\"stream channels are not a v1 runtime ABI\"), and the compiler does not yet emit it. The information below describes the intended shape so you can plan for it, not an API you can call right now.\n\nThe idea is **publish/subscribe** (\"pub/sub\"), a standard pattern for broadcast:\n\n- A **channel** is a named topic, for example a chat room id.\n- A connection **subscribes** to a channel to start receiving everything sent to it.\n- Anyone can **publish** a message to the channel, and every subscriber receives it.\n- A connection **unsubscribes** (or disconnects) to stop.\n\nWith `@channel`, the server box would join a connection to a named topic and publish messages to it, and the edge would deliver each published message to every subscriber across all the connections in that topic, no matter which worker or node each one landed on. That is the missing \"one to many\" piece that turns the echo example above into a real shared room. The plan already reserves per-plan limits for it (a cap on subscribers per channel and on message size), so the surface is designed; it is the runtime delivery that is not shipped yet.\n\n### The picture: world-wide sync (the design)\n\nWhen `@channel` ships, one `publish` will fan out across the whole edge mesh, reaching every subscriber wherever they are, at nearly the same moment. That is the **world-wide sync** idea: a live session where everyone sees the same update together, whether they are in the same city or on opposite sides of the planet.\n\n```mermaid\nflowchart TB\n Pub[\"One publish()<br/>(a chat line, a goal, a game tick)\"] --> Mesh[\"Dacely edge mesh<br/>(routes to every subscriber's core)\"]\n Mesh --> A[\"Subscriber in Tokyo\"]\n Mesh --> B[\"Subscriber in Paris\"]\n Mesh --> C[\"Subscriber in New York\"]\n Mesh --> D[\"...every other subscriber, at once\"]\n```\n\nThe mechanism meant to make this fast is the same one that already spreads plain connections across the edge: because the edge always knows where each subscriber's connection lives, a broadcast is a direct fan-out to a known set of destinations, not a search for who is listening. For the wider picture, see [Built for massive fan-out and world-wide sync](./README.md#built-for-massive-fan-out-and-world-wide-sync) in the overview.\n\nKeep the status in mind: the diagram above is the **intended shape**. The delivery runtime is not shipped yet, so treat it as the plan, not an API you can call today.\n\n**What you can build today:** anything where each user talks to their own box (assistants, per-user live updates, single-player sync, progress streams). **What needs `@channel`:** shared rooms and one-to-many broadcast. Until it ships, a common workaround is to persist messages to [the database](../database/README.md) and have clients poll or re-read, which is simpler but not instant.\n\n## Gotchas\n\n- **Frames are `string` or `ArrayBuffer`.** Decode binary frames with `TextDecoder`; there is no automatic parsing in `useChannel` (the typed `Server.Stream` client is the structured option).\n- **`send` before \"connected\" is dropped.** It is a no-op until the socket is open. Guard on `chan.connected`, or expect the first eager sends to be lost.\n- **`messages` grows forever.** It keeps every frame received. For a long-lived page, slice it or keep your own bounded list.\n- **One box per connection.** `useChannel` does not broadcast between users; that is the `@channel` feature described above.\n\n## Related\n\n- [Realtime overview](./README.md): the two-piece model and when to use realtime.\n- [Streams](./streams.md): the server `@stream` class this hook talks to.\n- [The database (ToilDB)](../database/README.md): where to persist messages that must outlive a connection or reach users who are offline.\n",
60
60
  "realtime/README.md": "# Realtime\n\nRealtime is how your app pushes data to the browser the instant it happens, instead of the browser having to ask again and again. In toiljs you get realtime from two small pieces: a server class marked `@stream`, and a client React hook called `useChannel`.\n\n## What \"realtime\" means\n\nA normal web request is one round trip. The browser asks (\"give me the todos\"), the server answers, and the connection closes. If you want fresh data a second later, you have to ask again. That is fine for a page load or a form submit, but it is a poor fit for anything that changes on its own: a live chat, a game, a price ticker, a progress bar, a presence indicator (\"3 people online\").\n\nRealtime flips it around. The browser opens **one long-lived connection** and keeps it open. After that, either side can send a message at any time, as many times as it likes, with no new handshake. That open connection is often called a **socket**.\n\n## WebTransport in plain words\n\nTo hold that long-lived connection open, the browser and the Dacely edge speak a protocol called **WebTransport**.\n\nHere is all you need to know as a beginner:\n\n- WebTransport is a modern, built-in browser feature (like `fetch`, but for a persistent two-way connection).\n- It runs on top of **HTTP/3** and **QUIC**, which are the newest, fastest versions of the web's plumbing. They are built for low latency (messages arrive quickly) and for surviving network changes (your phone switching from Wi-Fi to cellular without dropping the connection).\n- You do not write any WebTransport code by hand. toiljs gives you a tiny client API, and it uses WebTransport underneath on the production edge.\n\nOne helpful detail for later: in local development (`toiljs dev`) the same client API runs over a **WebSocket** instead, because a WebSocket is simpler to serve from one local process. A WebSocket is the older, widely supported \"keep a socket open\" browser feature. The point is that **your code is identical** in dev and in production; only the transport underneath differs, and toiljs picks it for you.\n\n## When to use realtime (and when not to)\n\nReach for realtime when **the server needs to talk first**, or when messages fly back and forth quickly:\n\n| Use realtime when... | Use a plain HTTP request when... |\n| --------------------------------------------- | ------------------------------------------------ |\n| A chat or comment thread updates live. | You load a page or a list once. |\n| A multiplayer game syncs moves and positions. | You submit a form and get one answer. |\n| You show live presence or typing indicators. | You fetch data on a button click. |\n| You stream progress of a long job. | The data rarely changes, or the user pulls it. |\n\n**Games are a first-class use case, not an afterthought.** Realtime multiplayer (player moves, low-latency input, live presence, per-player state) is one of the hardest things to build on the plain web, and it is exactly what toil streaming is designed for. More on why, and how far it is built to scale, in [Built for massive fan-out and world-wide sync](#built-for-massive-fan-out-and-world-wide-sync) below.\n\nIf a single request-and-response does the job, prefer that: it is simpler, it caches well, and it needs no open connection. Plain requests in toiljs are [HTTP routes](../backend/rest.md) and [typed RPC](../backend/rpc.md). Reach for realtime only when the \"ask again and again\" model genuinely gets in your way.\n\n## The two pieces\n\nRealtime in toiljs is always a pair:\n\n1. **The server: a `@stream` class.** You write a small class and mark it `@stream`. The edge turns it into a **resident box**: a live instance that is created when a connection opens, handles every message on that connection, and is torn down when it closes. It has four lifecycle hooks (`@connect`, `@message`, `@close`, `@disconnect`). See [Streams](./streams.md).\n\n2. **The client: a hook.** From your React UI you open the connection and send or receive messages. The low-level way is the `useChannel` hook; the typed, generated way is `Server.Stream.<ClassName>.connect()`. See [Channels](./channels.md).\n\n```mermaid\nsequenceDiagram\n participant B as Browser (React)\n participant E as Dacely edge\n participant S as Your @stream box\n B->>E: open connection (WebTransport, or WebSocket in dev)\n E->>S: create the box, run @connect\n Note over S: the box is now resident for this connection\n B->>E: send \"hello\"\n E->>S: @message(\"hello\")\n S-->>B: reply \"hi back\"\n B->>E: send \"still here\"\n E->>S: @message(\"still here\")\n S-->>B: reply \"yep\"\n B->>E: close\n E->>S: run @close, then destroy the box\n```\n\nNotice that the box lives across **many** messages in that diagram. That is the whole idea: because it is the same instance every time, it can remember things between messages (a counter, who you are, what room you joined). A normal HTTP handler forgets everything after each request; a stream box does not, for as long as the connection stays open.\n\n## Built for massive fan-out and world-wide sync\n\nRealtime gets exciting when a single event reaches **many** people at nearly the same moment. Picture a live sports app: a goal is scored, and every fan watching sees it light up together. Picture a multiplayer game: one player moves, and everyone in the match sees it happen right away. That shape, one event fanning out to a huge live audience, is what toil streaming is built for. We call it **world-wide packet sync**: everyone in the same live session gets the same update at nearly the same time, wherever they are on the planet.\n\n```mermaid\nflowchart TB\n P[\"One event<br/>(a goal, a chat line, a game move)\"] --> E[\"Dacely edge<br/>(spread across cores and cities)\"]\n E --> S1[\"Player in Tokyo\"]\n E --> S2[\"Player in Paris\"]\n E --> S3[\"Player in Sao Paulo\"]\n E --> Sn[\"...many more, at once\"]\n```\n\n### Why it can go so wide\n\nThree design choices make massive scale possible, without any single machine or single lock becoming the ceiling.\n\n1. **Connections spread across the fleet in parallel.** Every new connection is handled on its own, with no shared lock in the middle for connections to fight over. There is no central bottleneck that every connection has to pass through, so adding machines adds capacity in a straight line.\n2. **The session follows the user.** A phone that switches from Wi-Fi to cellular keeps its exact session, in-memory game state and all. A network change does not drop the connection or reset the box.\n3. **The session sits near the user.** The edge is a fleet of servers in many cities. A `@stream` runs at a regional or continental node (see [Compute tiers](../concepts/tiers.md)), so the round trip to a player stays short no matter where on the map they are.\n\n### An honest word on numbers\n\nThe framework is **designed** for very large live audiences, aiming at the scale of millions of concurrent connections in a single live session as its ambition. That is the target the architecture is built toward, not a number measured in these docs. Real capacity depends entirely on your deployment: how many machines and cores you run, how big each message is, how often it is sent, and where your users are. We do not publish a benchmarked figure here. What we can say honestly is **why** it scales (the three design choices above), and that the design removes the usual single-machine and single-lock ceilings that cap other realtime stacks.\n\nOne more honest note. Fanning a single message out to **other** users' connections (true one-to-many broadcast, the arrows in the diagram above) is the job of `@channel`. The client side of that (`useChannel`, `Server.Stream`) is live today. The server-side broadcast that reaches every subscriber across the mesh is **planned, not shipped yet**. See [Channels](./channels.md) for exactly what works now and what is coming, so you can build on solid ground.\n\n## Where to go next\n\n- [Streams](./streams.md): the server side. How to declare a `@stream` class, the four lifecycle hooks, per-connection state, replying, and the separate `main.stream.ts` file.\n- [Channels](./channels.md): the client side. The `useChannel` React hook, the generated typed client, and a chat-style example. Also covers the planned `@channel` broadcast feature.\n\n## Related\n\n- [Compute tiers (L1 to L4)](../concepts/tiers.md): where a stream box runs on the edge.\n- [HTTP routes (`@rest`)](../backend/rest.md) and [Typed RPC](../backend/rpc.md): the plain request-and-response alternatives.\n- [Daemons](../background/daemons.md): long-lived background work that is not tied to a browser connection.\n",
61
61
  "realtime/streams.md": "# Streams (`@stream`)\n\nA `@stream` is a server class that handles one live connection from start to finish. You mark a class with `@stream`, add lifecycle hooks, and the Dacely edge keeps one instance of that class alive for as long as the browser stays connected.\n\n## What a stream is (and why it is different)\n\nWhen you write an [HTTP route](../backend/rest.md), the server builds a **fresh** handler for every request and throws it away afterward. Anything you stored on the handler's fields is gone the moment the response is sent. That is perfect for one-shot requests, but it means the handler cannot \"remember\" anything between requests on its own.\n\nA `@stream` is the opposite. It is a **resident box**: one live instance, created when a connection opens and kept alive until it closes. Because it is the *same* instance for every message on that connection, values you store on its fields **persist across messages**. That is what makes it the right tool for a conversation, a game session, or anything stateful that lasts for the life of a connection.\n\nThe word \"resident\" just means \"stays in memory and keeps running.\" The word \"box\" is toiljs's name for one sandboxed instance of your compiled server code.\n\n```ts\n@stream('echo')\nclass Echo {\n private count: i32 = 0;\n\n @connect\n onConnect(): void {\n this.count = 0; // a fresh connection starts a fresh box, so count begins at 0\n }\n\n @message\n onMessage(packet: StreamPacket): StreamOutbound {\n this.count = this.count + 1; // survives across messages: same box every time\n const text = 'pong #' + this.count.toString();\n return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(text)));\n }\n\n @close\n onClose(): void {\n // the box is destroyed after this hook runs\n }\n}\n```\n\nConnect, send three messages, and you get back `pong #1`, `pong #2`, `pong #3`. The advancing number is proof that the same box handled all three.\n\n## Declaring a stream\n\nMark a class with `@stream` and give it a **name**. The name becomes the route the browser connects to.\n\n```ts\n@stream('echo') // mounted at /echo\nclass Echo { /* ... */ }\n\n@stream // bare form: the route is the class name (/Echo)\nclass Echo { /* ... */ }\n```\n\nThere are three forms:\n\n- `@stream('name')`: an explicit mount name (connect at `/name`).\n- `@stream` (bare): the mount name is the class name.\n- `@stream({ ... })`: a config object (see [Configuration](#configuration) below).\n\n## The four lifecycle hooks\n\nA stream method becomes a lifecycle hook when you tag it with one of these decorators. Every hook is **optional**: declare only the ones you need, and a missing hook is simply a no-op (it does nothing, it never crashes).\n\n| Decorator | Fires when... |\n| ------------- | ----------------------------------------------------------------------- |\n| `@connect` | the connection opens (the box has just been created). |\n| `@message` | an inbound frame arrives from the browser. |\n| `@close` | the connection closes cleanly (the box is destroyed after this hook). |\n| `@disconnect` | the connection is lost abruptly (network dropped, browser killed). |\n\nA **frame** is one message: one call to `send()` on the client becomes one `@message` on the server.\n\n```mermaid\nsequenceDiagram\n participant B as Browser\n participant S as @stream box\n B->>S: open\n activate S\n Note over S: @connect runs, box is created\n B->>S: frame \"a\"\n S-->>B: @message -> reply\n B->>S: frame \"b\"\n S-->>B: @message -> reply\n B->>S: close\n Note over S: @close runs, box is destroyed\n deactivate S\n```\n\n### `@connect`\n\nRuns once, right after the box is created. Use it to set up per-connection state (reset a counter, read the requested path, decide whether to accept the connection). It can return a `StreamOutbound` to accept or reject (see below). The Echo example uses it to zero its counter.\n\n**What it receives.** `@connect` is handed a `StreamInbound`, a small read-only object the host fills in with the details of the connection that just opened. It lets you look at *where* the connection came from before you decide to keep it.\n\n| Member | Type | What it gives you |\n| ------------- | -------- | ----------------------------------------------------------------------- |\n| `streamId` | `u64` | A unique id for this connection. |\n| `transport` | `i32` | A numeric tag for the transport that carried the connection. |\n| `authority()` | `string` | The host the browser connected to (for example `example.com`). |\n| `path()` | `string` | The path the browser opened (for example `/echo`). |\n\nWatch the shape: `authority()` and `path()` are **methods** (call them with `()`), while `streamId` and `transport` are plain properties (no parentheses).\n\n```ts\n@connect\nonConnect(info: StreamInbound): StreamOutbound {\n // Inspect where the connection is headed, then decide whether to keep it.\n if (info.path() != '/echo') {\n return StreamOutbound.reject(1); // refuse: any u16 reason code\n }\n this.count = 0; // fresh connection, fresh state\n return StreamOutbound.accept(); // keep the connection open\n}\n```\n\nYou do not have to take the argument. If the hook needs none of these details, declare it with no parameter (`onConnect(): void`), exactly like the Echo example above.\n\n### `@message`\n\nRuns for every inbound frame. This is where most of your logic lives. It receives the frame and may reply. Details in [Reading and replying](#reading-and-replying-to-messages).\n\n### `@close` and `@disconnect`\n\nBoth mean \"the connection is over,\" and both are your chance to clean up. The difference is *how* it ended:\n\n- `@close` is a **graceful** close: the browser (or your server) ended it on purpose.\n- `@disconnect` is an **abrupt** loss: the network dropped or the tab was killed with no goodbye.\n\nAfter either one, the box is destroyed.\n\n**What they receive.** Both hooks are handed a `StreamConnectionEvent`, a read-only summary of the connection that just ended.\n\n| Member | Type | What it gives you |\n| -------------- | ----- | ---------------------------------------------------------- |\n| `connectionId` | `u64` | The id of the connection that ended. |\n| `reason` | `u16` | A numeric close code explaining why it ended. |\n| `durationMs` | `u64` | How long the connection stayed open, in milliseconds. |\n\nAll three are plain properties (getters), so read them without `()`.\n\n```ts\n@close\nonClose(ev: StreamConnectionEvent): void {\n // Last chance to clean up. `ev` tells you how the connection ended.\n const heldSeconds = ev.durationMs / 1000;\n // e.g. record the session length or flush a buffer here.\n}\n\n@disconnect\nonDisconnect(ev: StreamConnectionEvent): void {\n // Same shape, but this fired because the connection dropped abruptly.\n // ev.reason carries the close code the edge assigned to the drop.\n}\n```\n\nAs with `@connect`, the argument is optional: declare `onClose(): void` if you do not need it.\n\n## Per-connection state (and its limits)\n\nState on the box's fields lasts for **one connection**. It does **not** survive:\n\n- a **reconnect**: if the browser drops and reopens, it gets a brand-new box that starts clean.\n- a **different user**: every connection gets its own box, so one connection's state can never leak into another. This is a safety property, not just a convenience.\n\nSo treat box fields as **per-connection scratch space** only. For anything that must outlive the connection (a saved message, a score, who a user is across reconnects), write it to [the database](../database/README.md), not to a class field.\n\n## Reading and replying to messages\n\nBy default, a `@message` receives a `StreamPacket`, which is a thin view over the raw bytes that arrived, and returns a `StreamOutbound`, which stages the reply.\n\n```ts\n@message\nonMessage(packet: StreamPacket): StreamOutbound {\n const raw = packet.bytes(); // the inbound frame as bytes\n return StreamOutbound.reply(raw); // echo the same bytes back\n}\n```\n\n`StreamPacket` (the inbound frame):\n\n| Member | What it gives you |\n| ------------ | ---------------------------------------------------------- |\n| `bytes()` | the whole frame as a `Uint8Array` (copy it if you keep it). |\n| `length` | the number of bytes in the frame. |\n| `at(i)` | the byte at index `i`. |\n\n`StreamOutbound` (what you return):\n\n| Call | Meaning |\n| ----------------------------- | ------------------------------------------------------------------ |\n| `StreamOutbound.reply(bytes)` | send one frame back to the browser. |\n| `StreamOutbound.empty()` | accept the frame and send nothing back. |\n| `StreamOutbound.reject(code)` | refuse (used from `@connect` to turn a connection away). |\n| `StreamOutbound.accept()` | accept a connection with no reply frame. |\n\nA `@message` may also return `void` when it never replies.\n\n> **Bytes, not strings.** A frame is raw bytes on the wire. To send text, encode it with `String.UTF8.encode(...)` (server) or `new TextEncoder().encode(...)` (browser), and decode it with `new TextDecoder().decode(...)` on the other side.\n\n### Typed messages\n\nRaw bytes are flexible but fiddly. If your messages are structured, declare a [`@data`](../backend/data.md) class and pass it as the stream's `message` type. Your `@message` hook then receives the **decoded object** instead of raw bytes.\n\n```ts\n@data\nclass ChatMsg {\n text: string = '';\n constructor(text: string = '') { this.text = text; }\n}\n\n@stream({ message: ChatMsg })\nclass Chat {\n @message\n onMessage(msg: ChatMsg): StreamOutbound { // decoded @data, not raw bytes\n const echoed = 'you said: ' + msg.text;\n return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(echoed)));\n }\n}\n```\n\nThe reply is still raw (`StreamOutbound` deals in bytes). Only the **inbound** side is decoded for you. On the client, a typed stream lets you `send(new ChatMsg('hi'))` and toiljs encodes it for you.\n\n## Games and interactive apps\n\nA stream box remembers state for the life of a connection, which is exactly what a game session needs. Each connected player gets their **own** box, and that box holds the player's live state (position, health, score) in memory, right next to the core handling their packets. The lifecycle hooks map cleanly onto a session: `@connect` spawns the player, `@message` handles each input, `@close` and `@disconnect` remove them.\n\n```ts\n// server/streams/Match.ts\n@data\nclass Move { // the input a player sends each tick\n dx: i32 = 0;\n dy: i32 = 0;\n constructor(dx: i32 = 0, dy: i32 = 0) { this.dx = dx; this.dy = dy; }\n}\n\n@stream({ message: Move, scope: StreamScope.Regional })\nclass Match {\n // Per-connection state: this player's position. It lives in memory for the\n // whole session, on the one core that owns this connection.\n private x: i32 = 0;\n private y: i32 = 0;\n private moves: u32 = 0;\n\n @connect\n onConnect(info: StreamInbound): StreamOutbound {\n this.x = 50; // spawn point\n this.y = 50;\n this.moves = 0;\n return StreamOutbound.accept();\n }\n\n @message\n onMove(move: Move): StreamOutbound {\n // Apply the input to this player's live position, then confirm it.\n this.x = this.x + move.dx;\n this.y = this.y + move.dy;\n this.moves = this.moves + 1;\n const state = '{\"x\":' + this.x.toString() + ',\"y\":' + this.y.toString() + '}';\n return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(state)));\n }\n\n @disconnect\n onDrop(ev: StreamConnectionEvent): void {\n // The player left (tab closed, network died). Their box is destroyed\n // next. Persist a score here if it must outlive the session.\n }\n}\n```\n\nEvery input a player sends is one `@message`, applied to **their** box's position and confirmed straight back to them with low latency. Because the box is resident, the player's position is always there in memory, on the same core, with no database round trip per move. That is what makes fast input loops (a game tick, a cursor drag, a live editor) feel instant.\n\n**What this version does, and does not, do.** Each player has their own box, so this handles per-player input, per-player state, and instant confirmation back to that player. To make players see **each other** (the other half of multiplayer), one player's move must fan out to everyone else in the match. That cross-connection broadcast is the `@channel` feature, which is **planned, not live yet** (see [Channels](./channels.md)). Until it ships, the common patterns are to write shared match state to [the database](../database/README.md) and have clients read it, or to keep a match to a single authoritative box. The per-connection pieces above (input, state, and presence via `@connect` / `@disconnect`) work today.\n\nFor **presence** (\"who is online\"), `@connect` and `@disconnect` are your join and leave signals: bump a counter or write a row when a player connects, and undo it when they drop.\n\n## The `main.stream.ts` file (a separate tier)\n\nStreams live in their **own entry file**, `server/main.stream.ts`, separate from the request entry `server/main.ts`. Importing your `@stream` classes there pulls them into a **separate compiled artifact**, `build/server/release-stream.wasm`.\n\n```ts\n// server/main.stream.ts\nimport { revertOnError } from 'toiljs/server/runtime/abort/abort';\n\nimport './streams/Echo'; // add each @stream module here as you grow\nimport './streams/Chat';\n\n// Re-export the WASM entry points the host binds, exactly like main.ts.\nexport * from 'toiljs/server/runtime/exports';\nexport function abort(message: string, fileName: string, line: u32, column: u32): void {\n revertOnError(message, fileName, line, column);\n}\n```\n\nWhy a separate file? A stream box and a request handler are **different kinds of program** that run on different parts of the edge (see [Compute tiers](../concepts/tiers.md)). toiljs compiles each into its own `.wasm`:\n\n```sh\n$ toiljs build\n$ ls build/server/*.wasm\nbuild/server/release.wasm # L1 request (@rest / @service)\nbuild/server/release-stream.wasm # L2/L3 stream (@stream)\nbuild/server/release-cold.wasm # L4 daemon (@daemon)\n```\n\nYou do not run this by hand. `toiljs build` produces `release-stream.wasm` automatically whenever your project has a `@stream` surface, and shared helper code and `@data` types are compiled into every artifact.\n\n> **One file cannot be both a stream and an RPC surface.** A single source file may not declare both a `@stream` and a `@service` / `@remote` ([RPC](../backend/rpc.md)), because one compiled artifact cannot be two tiers at once. Keep them in separate files: `@stream` in `main.stream.ts`, `@rest` / `@service` in `main.ts`. They coexist happily side by side in the same project, just not in the same file.\n\n## Configuration\n\nThe config-object form lets you tune a stream:\n\n```ts\n@stream({\n scope: StreamScope.Regional, // where the box runs (see below)\n message: ChatMsg, // decode inbound frames into this @data type\n maxFrameBytes: 65536, // reject frames larger than this\n ingressRingBytes: 262144 // size of the inbound buffer\n})\nclass Chat { /* ... */ }\n```\n\n- **`scope`** picks how close to the user the box runs. `StreamScope.Regional` (the default) runs it at a regional node; `StreamScope.Continental` runs it at a wider continental node. See [Compute tiers](../concepts/tiers.md) for what L2 and L3 mean.\n- **`message`** is the typed-message shortcut described above.\n- **`maxFrameBytes`** and **`ingressRingBytes`** cap frame size and buffer size to protect the box from oversized or flooding input.\n\n## Reaching a stream from the browser\n\nEvery `@stream` class gets a generated, typed client at `Server.Stream.<ClassName>`, wired up for you in `shared/server.ts` (the same place the [RPC](../backend/rpc.md) client lands). Call `connect()` to open the connection:\n\n```ts\nimport '../shared/server'; // attaches globalThis.Server (browser-only)\n\nconst chat = await Server.Stream.Echo.connect();\nchat.onMessage((bytes) => { /* a reply frame, always raw bytes */ });\nchat.send(new TextEncoder().encode('hello'));\nchat.onClose((code) => { /* the connection ended */ });\nchat.close();\n```\n\nThe client is keyed by the **class name** (`Server.Stream.Echo`) and connects to the class's **mount route** (`/echo`). Inbound replies are always raw bytes. The full client walkthrough, plus the lower-level `useChannel` hook, is in [Channels](./channels.md).\n\n## How placement works (you do not manage it)\n\nOn the production edge, your box lives in **one place** for the connection's whole life. Every message from that connection is routed to the exact instance holding your box, so its in-memory state is always there. You never configure this; the edge does it automatically. In `toiljs dev` there is only one process, so this is a non-issue.\n\n## Why this scales\n\nA `@stream` box is deliberately cheap to run at scale, and the edge is built to spread a very large number of them across many machines and cities. Three design choices do the heavy lifting:\n\n- **Connections spread across the fleet in parallel.** Each connection is handled on its own, with no shared lock in the middle for connections to fight over. There is no central bottleneck every connection has to pass through, so adding machines adds capacity in a straight line.\n- **The connection survives network changes.** If a client's network changes (Wi-Fi to cellular, or a NAT rebind), the edge keeps the connection attached to the same box. The user keeps their session and their in-memory state; the box is never orphaned.\n- **Each box is isolated and bounded.** Every connection gets its own sandboxed box with its own linear memory. One tenant's boxes are capped to a slice of the node's RAM (a noisy-neighbor guard), and a single box is hard-capped (64 MiB) so a runaway connection can only fill itself, then it traps. Each lifecycle hook also runs under a hard compute cap, so a hook that loops forever is stopped instead of hogging the machine.\n\nPut together: connections spread across the fleet, run in parallel, the session sticks to its box even when the network moves, and each box is walled off from the others. That is what lets one deployment hold a very large number of live connections at once. How large depends on your hardware and message sizes; these docs do not publish a benchmarked number.\n\n> **The wider picture.** For how the edge spreads connections across the fleet and the mesh adds up to world-wide fan-out, see [Built for massive fan-out and world-wide sync](./README.md#built-for-massive-fan-out-and-world-wide-sync) in the overview.\n\n## Gotchas\n\n- **Box fields are per-connection only.** They reset on reconnect and are never shared between users. Persist anything durable to [the database](../database/README.md).\n- **Frames are bytes.** Encode and decode text yourself, or use a typed `message` so toiljs does it.\n- **Copy `packet.bytes()` if you keep it.** The inbound buffer is reused after the hook returns, so store a copy if you need the bytes later.\n- **A file cannot mix `@stream` with `@service` / `@remote`.** Keep streams in `main.stream.ts`.\n- **`@channel` is not live yet.** A stream that declares a `@channel` hook is rejected by the edge today. Broadcasting to many subscribers is a planned feature; see [Channels](./channels.md).\n\n## Related\n\n- [Realtime overview](./README.md): the big picture and when to reach for realtime.\n- [Channels](./channels.md): the client `useChannel` hook and a chat-style example.\n- [Compute tiers (L1 to L4)](../concepts/tiers.md): where the stream artifact runs.\n- [Data types (`@data`)](../backend/data.md): typed messages.\n- [The database (ToilDB)](../database/README.md): where to keep state that outlives a connection.\n",
62
- "services/analytics.md": "# Analytics\n\nRead your site's own usage numbers (requests, bytes, cache hits, database ops, errors, and more) straight from a route, with no extra code and no third-party service.\n\nReach for analytics to build a status or usage dashboard for a site: how much traffic it serves, how effective its cache is, how many database operations it runs. It is **infrastructure metering per domain**, not per-user product analytics. There are no custom events; you read a fixed catalog of counters the edge already keeps for you.\n\n`Analytics` is an **ambient global**: use it with no import, like [`crypto`](./crypto.md) or `Time`.\n\n## What \"metering\" means\n\nEvery time the edge (the Dacely server running your code) serves a request, runs a database operation, opens a stream, or sends an email, it **counts** that into a set of per-domain counters. Those counters live in the same worldwide, distributed database that backs [ToilDB](../database/README.md), so the total for your site is correct across every edge location, not just the one machine that happened to serve a request.\n\n`Analytics.self()` reads a **snapshot** of your site's current counter values. You do not record anything yourself; the numbers are already there.\n\n```mermaid\nflowchart LR\n R[\"Every request, DB op,<br/>stream, email\"] --> C[\"Edge counts it into<br/>per-domain counters\"]\n C --> D[(\"Distributed counters,<br/>worldwide\")]\n D --> S[\"Analytics.self()<br/>reads a snapshot\"]\n S --> Y[\"Your route returns<br/>the numbers\"]\n```\n\n## Read your site's stats\n\n`Analytics.self()` returns a `TenantStats` object. Every counter is a **named getter** that returns a `u64` (an unsigned 64-bit integer, always zero or positive), so mapping the numbers into a response needs no casts at all.\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n@rest('metrics')\nclass Metrics {\n @get('/')\n public overview(ctx: RouteContext): string {\n const s = Analytics.self();\n // Every getter is a u64. cacheRatio is the one f64 (a fraction 0..1).\n return `requests=${s.requests} cacheHits=${s.cacheHits} ratio=${s.cacheRatio}`;\n }\n}\n```\n\nYou can also read any counter by its numeric id with `s.metric(MetricId.Requests)`, which returns `0` for an unknown id. The named getters are preferred: they are self-documenting and there are no magic strings to mistype.\n\n### The counter catalog\n\nEvery getter below is a lifetime running total: it only ever goes up, and reading it never resets it. They are grouped by area. (`MetricId` is the matching numeric id, an ambient enum you can use with no import.)\n\n**Requests and the L1 edge**\n\n| Getter | What it counts |\n| --- | --- |\n| `requests` | HTTP requests served |\n| `bytesOutL1` | Response bytes sent |\n| `bytesInL1` | Request bytes received |\n| `status2xx` | 2xx responses (success) |\n| `status3xx` | 3xx responses (redirects) |\n| `status4xx` | 4xx responses (client errors) |\n| `status5xx` | 5xx responses (server errors) |\n| `staticHits` | Static assets served without running your code |\n| `wasmDispatches` | Times your compiled handler ran |\n| `executorFullRejects` | Requests rejected because the worker pool was full |\n| `unknownHostRejects` | Requests rejected for an unknown host |\n| `rateLimitedRejects` | Requests rejected by rate limiting |\n| `gasUsed` | Total compute \"gas\" your handlers consumed |\n\n**Database (ToilDB)**\n\n| Getter | What it counts |\n| --- | --- |\n| `dbOps` | Database operations issued |\n| `dbReads` | Read operations |\n| `dbWrites` | Write operations |\n| `dbErrors` | Operations that errored |\n| `dbLatencyNsSum` | Summed operation latency, in nanoseconds |\n| `meanDbLatencyNs` | Derived: `dbLatencyNsSum / dbOps` (0 with no ops) |\n\n**Streams (WebTransport realtime)**\n\n| Getter | What it counts |\n| --- | --- |\n| `streamAccepts` | Stream connections accepted |\n| `streamRejectWrongNode` | Streams that reached the wrong node |\n| `streamRejectCapacity` | Streams rejected for capacity |\n| `streamRejectArtifact` | Streams rejected for a missing or invalid build |\n| `streamRejectGuest` | Streams your code rejected |\n| `streamTraps` | Stream handler crashes |\n| `streamIdleTimeouts` | Streams closed for being idle |\n| `streamBytesIn` / `streamBytesOut` | Bytes received / sent on streams |\n| `streamBackpressureEvents` | Times a stream had to slow down |\n| `streamCloses` | Clean closes |\n| `streamDisconnects` | Abrupt disconnects |\n\n**Daemons (L4 background jobs)**\n\n| Getter | What it counts |\n| --- | --- |\n| `daemonStarts` / `daemonStartFailures` | Daemon starts / failed starts |\n| `daemonTicksFired` | Scheduled ticks that ran |\n| `daemonTicksSkippedNotLeader` | Ticks skipped because this node was not the leader |\n| `daemonTicksFailed` | Ticks that failed |\n| `daemonLeaderAcquires` / `daemonLeaderFenced` | Leadership gained / lost |\n| `daemonHttpCallAttempts` / `daemonHttpCallFailures` | Outbound HTTP calls attempted / failed |\n\n**Memory, email, and cache**\n\n| Getter | What it counts |\n| --- | --- |\n| `memGrownBytes` | Wasm memory grown, in bytes |\n| `emails` | Emails sent |\n| `cacheHits` | Responses served from the edge cache |\n| `cacheMisses` | Cacheable responses that missed the cache |\n| `cacheRatio` | Derived **`f64`**: `cacheHits / (cacheHits + cacheMisses)`, a fraction from 0 to 1 (0 when there were no cacheable responses) |\n\n### Live gauges and request windows\n\nA few fields are not lifetime totals. They still read as `u64`.\n\n**Live gauges** are the current level right now, not a running total:\n\n- `connectedStreams`: streams connected at this moment.\n- `committedMemory`: wasm memory committed right now, in bytes.\n\n**Request windows** show your current rate-limit usage against your plan cap (a cap of `0` means unlimited):\n\n- `reqMinuteUsed` / `reqMinuteCap`: requests used and the cap for the current 1-minute window.\n- `reqDayUsed` / `reqDayCap`: requests used and the cap for the current 24-hour window.\n\nThere is also `nowMs`, the edge wall-clock time (in milliseconds) when the snapshot was taken, so you can show \"as of\" and compute how fresh the numbers are.\n\n## Time series (for graphs)\n\nThe totals above are single numbers. To draw a graph over time, use `Analytics.series(metric, range)`, which returns a `Series`: a list of per-bucket totals from oldest to newest.\n\n```ts\n@get('/requests-24h')\npublic requests24h(ctx: RouteContext): string {\n const s = Analytics.series(MetricId.Requests, AnalyticsRange.H24);\n // s.points -> per-bucket totals, oldest to newest\n // s.bucketSecs -> seconds each bucket covers\n // s.headMs -> end time (ms) of the newest bucket\n // s.ratePerSec(i) -> requests/second for bucket i (points[i] / bucketSecs)\n return `buckets=${s.points.length} bucketSecs=${s.bucketSecs}`;\n}\n```\n\n`AnalyticsRange` picks the window and the resolution. Short ranges use per-minute buckets for a smooth line; the rest use per-hour buckets (kept for 30 days):\n\n| Range | Span | Bucket width |\n| --- | --- | --- |\n| `H1` | 1 hour | 1 minute |\n| `H6` | 6 hours | 1 minute |\n| `H12` | 12 hours | 1 hour |\n| `H24` | 24 hours | 1 hour |\n| `D3` | 3 days | 1 hour |\n| `D7` | 7 days | 1 hour |\n| `D14` | 14 days | 1 hour |\n| `D30` | 30 days | 1 hour |\n\nThe series stores **totals per bucket**, never rates. A rate is derived: `ratePerSec(i)` divides bucket `i` by its width for you, or you can compute `points[i] / bucketSecs` yourself. Most metrics you will chart are counters (`MetricId.Requests`, `MetricId.CacheHits`, and so on).\n\n## Permissions: who can read what\n\n- **Your own site.** `Analytics.self()` and `Analytics.series(...)` always read the site the request landed on. The calling domain is decided by the edge; your code cannot forge it.\n- **Cross-site reads are for `dacely.com` only.** The privileged `dacely.com` domain (the platform dashboard) may read any site with `Analytics.site(domain)`, `Analytics.siteSeries(domain, ...)`, and `Analytics.listSites(...)`. For any other caller, or an unknown domain, `site` and `siteSeries` return `null` and `listSites` returns an empty page. There is no way for a normal site to read another site's numbers.\n\n`Analytics.listSites(cursor, limit)` enumerates site names for the dashboard, one page at a time. It returns a `SiteList` with `sites: string[]` and `hasMore: bool`. When `hasMore` is true, pass the last name back as the next `cursor` to get the following page.\n\n```ts\n// Only meaningful when the calling domain is dacely.com.\nconst page = Analytics.listSites('', 100); // first 100 site names\nfor (let i = 0; i < page.sites.length; i++) {\n const other = Analytics.site(page.sites[i]); // TenantStats | null\n if (other != null) {\n // read other.requests, other.cacheRatio, ...\n }\n}\n```\n\n## Worked example: return everything\n\nThis route reads the full snapshot and maps it into a typed response. Mapping is mechanical: every getter is a `u64` and every response field is a `u64`, so there are no casts. The example groups a representative field from each area; add the rest of the getters from the catalog above the same way.\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n/** The shape returned to the client. All fields are u64 except the one ratio. */\n@data\nexport class UsageReport {\n // requests / L1\n requests: u64 = 0;\n bytesOut: u64 = 0;\n status2xx: u64 = 0;\n status5xx: u64 = 0;\n gasUsed: u64 = 0;\n // database\n dbOps: u64 = 0;\n dbErrors: u64 = 0;\n meanDbLatencyNs: u64 = 0;\n // cache\n cacheHits: u64 = 0;\n cacheMisses: u64 = 0;\n cacheRatio: f64 = 0;\n // live gauges (current level, not a total)\n connectedStreams: u64 = 0;\n committedMemory: u64 = 0;\n // request windows (cap 0 = unlimited)\n requestsThisMinute: u64 = 0;\n requestsThisMinuteCap: u64 = 0;\n requestsToday: u64 = 0;\n requestsTodayCap: u64 = 0;\n // when the snapshot was read (edge ms)\n nowMs: u64 = 0;\n}\n\n@rest('usage')\nclass Usage {\n @get('/')\n public report(ctx: RouteContext): UsageReport {\n const s = Analytics.self();\n const out = new UsageReport();\n\n out.requests = s.requests;\n out.bytesOut = s.bytesOutL1;\n out.status2xx = s.status2xx;\n out.status5xx = s.status5xx;\n out.gasUsed = s.gasUsed;\n\n out.dbOps = s.dbOps;\n out.dbErrors = s.dbErrors;\n out.meanDbLatencyNs = s.meanDbLatencyNs;\n\n out.cacheHits = s.cacheHits;\n out.cacheMisses = s.cacheMisses;\n out.cacheRatio = s.cacheRatio;\n\n out.connectedStreams = s.connectedStreams;\n out.committedMemory = s.committedMemory;\n\n out.requestsThisMinute = s.reqMinuteUsed;\n out.requestsThisMinuteCap = s.reqMinuteCap;\n out.requestsToday = s.reqDayUsed;\n out.requestsTodayCap = s.reqDayCap;\n\n out.nowMs = s.nowMs;\n return out;\n }\n}\n```\n\nBecause this is a `@data` return type, the client gets a fully typed result from `Server.REST.usage.report()`. See [structured data types](../backend/data.md) for how `@data` works.\n\n## Local dev behavior\n\nUnder `toiljs dev`, the real per-domain metering does not exist (there is only one machine and no fleet), so the dev server returns a **fixed sample** `TenantStats` and a gentle synthetic ramp for `series(...)`. That lets you build and style a dashboard locally. Dev is also permissive: it returns sample data for any domain, because the real `dacely.com`-only check is enforced on the edge. Your code is unchanged; only the numbers differ.\n\n## Gotchas and limits\n\n- **Totals never reset.** The counter getters are lifetime running totals. To see \"requests in the last hour\", use `series(...)` and sum the buckets, not the totals.\n- **Values are `u64`.** Map them into `u64` fields with no casts. The only non-integer field is `cacheRatio` (and `Series.ratePerSec`), which are `f64`.\n- **Not per-user analytics.** These are infrastructure counters per domain. There is no way to record a custom event or attribute a number to a specific user.\n- **Cross-site reads need `dacely.com`.** `Analytics.site(...)` returns `null` for everyone else. Do not build a feature that reads another site's stats.\n- **Snapshots, not live streams.** Each call reads the current values once. `nowMs` tells you when.\n\n## Related\n\n- [Caching](./caching.md), the source of `cacheHits`, `cacheMisses`, and `cacheRatio`.\n- [ToilDB database](../database/README.md), the source of the `db*` counters and where these counters are stored.\n- [Structured data types](../backend/data.md), for the `@data` return type in the worked example.\n- [Compute tiers](../concepts/tiers.md), for what \"L1\", \"streams\", and \"daemons\" mean in the catalog.\n",
62
+ "services/analytics.md": "# Analytics\n\nRead your site's own usage numbers (requests, bytes, cache hits, database ops, errors, and more) straight from a route, with no extra code and no third-party service.\n\nReach for analytics to build a status or usage dashboard for a site: how much traffic it serves, how effective its cache is, how many database operations it runs. It is **infrastructure metering per domain**, not per-user product analytics. There are no custom events; you read a fixed catalog of counters the edge already keeps for you.\n\n`Analytics` is an **ambient global**: use it with no import, like [`crypto`](./crypto.md) or `Time`.\n\n## What \"metering\" means\n\nEvery time the edge (the Dacely server running your code) serves a request, runs a database operation, opens a stream, or sends an email, it **counts** that into a set of per-domain counters. Those counters live in the same worldwide, distributed database that backs [ToilDB](../database/README.md), so the total for your site is correct across every edge location, not just the one machine that happened to serve a request.\n\n`Analytics.self()` reads a **snapshot** of your site's current counter values. You do not record anything yourself; the numbers are already there.\n\n```mermaid\nflowchart LR\n R[\"Every request, DB op,<br/>stream, email\"] --> C[\"Edge counts it into<br/>per-domain counters\"]\n C --> D[(\"Distributed counters,<br/>worldwide\")]\n D --> S[\"Analytics.self()<br/>reads a snapshot\"]\n S --> Y[\"Your route returns<br/>the numbers\"]\n```\n\n## Read your site's stats\n\n`Analytics.self()` returns a `TenantStats` object. Every counter is a **named getter** that returns a `u64` (an unsigned 64-bit integer, always zero or positive), so mapping the numbers into a response needs no casts at all.\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n@rest('metrics')\nclass Metrics {\n @get('/')\n public overview(ctx: RouteContext): string {\n const s = Analytics.self();\n // Every getter is a u64. cacheRatio is the one f64 (a fraction 0..1).\n return `requests=${s.requests} cacheHits=${s.cacheHits} ratio=${s.cacheRatio}`;\n }\n}\n```\n\nYou can also read any counter by its numeric id with `s.metric(MetricId.Requests)`, which returns `0` for an unknown id. The named getters are preferred: they are self-documenting and there are no magic strings to mistype.\n\n### The counter catalog\n\nEvery getter below is a lifetime running total: it only ever goes up, and reading it never resets it. They are grouped by area. (`MetricId` is the matching numeric id, an ambient enum you can use with no import.)\n\n**Requests and the L1 edge**\n\n| Getter | What it counts |\n| --- | --- |\n| `requests` | HTTP requests served |\n| `bytesOutL1` | Response bytes sent |\n| `bytesInL1` | Request bytes received |\n| `status2xx` | 2xx responses (success) |\n| `status3xx` | 3xx responses (redirects) |\n| `status4xx` | 4xx responses (client errors) |\n| `status5xx` | 5xx responses (server errors) |\n| `staticHits` | Static assets served without running your code |\n| `wasmDispatches` | Times your compiled handler ran |\n| `executorFullRejects` | Requests rejected because the worker pool was full |\n| `unknownHostRejects` | Requests rejected for an unknown host |\n| `rateLimitedRejects` | Requests rejected by rate limiting |\n| `gasUsed` | Total compute \"gas\" your handlers consumed |\n\n**Database (ToilDB)**\n\n| Getter | What it counts |\n| --- | --- |\n| `dbOps` | Database operations issued |\n| `dbReads` | Read operations |\n| `dbWrites` | Write operations |\n| `dbErrors` | Operations that errored |\n| `dbLatencyNsSum` | Summed operation latency, in nanoseconds |\n| `meanDbLatencyNs` | Derived: `dbLatencyNsSum / dbOps` (0 with no ops) |\n\n**Streams (WebTransport realtime)**\n\n| Getter | What it counts |\n| --- | --- |\n| `streamAccepts` | Stream connections accepted |\n| `streamRejectWrongNode` | Streams that reached the wrong node |\n| `streamRejectCapacity` | Streams rejected for capacity |\n| `streamRejectArtifact` | Streams rejected for a missing or invalid build |\n| `streamRejectGuest` | Streams your code rejected |\n| `streamTraps` | Stream handler crashes |\n| `streamIdleTimeouts` | Streams closed for being idle |\n| `streamBytesIn` / `streamBytesOut` | Bytes received / sent on streams |\n| `streamBackpressureEvents` | Times a stream had to slow down |\n| `streamCloses` | Clean closes |\n| `streamDisconnects` | Abrupt disconnects |\n\n**Daemons (L4 background jobs)**\n\n| Getter | What it counts |\n| --- | --- |\n| `daemonStarts` / `daemonStartFailures` | Daemon starts / failed starts |\n| `daemonTicksFired` | Scheduled ticks that ran |\n| `daemonTicksSkippedNotLeader` | Ticks skipped because this node was not the leader |\n| `daemonTicksFailed` | Ticks that failed |\n| `daemonLeaderAcquires` / `daemonLeaderFenced` | Leadership gained / lost |\n| `daemonHttpCallAttempts` / `daemonHttpCallFailures` | Outbound HTTP calls attempted / failed |\n\n**Memory, email, and cache**\n\n| Getter | What it counts |\n| --- | --- |\n| `memGrownBytes` | Wasm memory grown, in bytes |\n| `emails` | Emails sent |\n| `cacheHits` | Responses served from the edge cache |\n| `cacheMisses` | Cacheable responses that missed the cache |\n| `cacheRatio` | Derived **`f64`**: `cacheHits / (cacheHits + cacheMisses)`, a fraction from 0 to 1 (0 when there were no cacheable responses) |\n\n### Live gauges and request meters\n\nA few fields are not lifetime totals. They still read as `u64` (except the derived `sustainedRpsCap`, an `f64`).\n\n**Live gauges** are the current level right now, not a running total:\n\n- `connectedStreams`: streams connected at this moment.\n- `committedMemory`: wasm memory committed right now, in bytes.\n\n**Request meters** show where you sit against your plan. The model is a **sustained-rate (\"unmetered pipe\")** one, not a fixed per-minute ceiling. Your plan has a **sustained request rate** `X` (requests per second), measured over a **3-minute rolling window**. Two important consequences:\n\n- **Instantaneous spikes are free.** There is no per-tier cap on how fast you may burst inside a window; you may spike right up to the box's own hardware ceiling (the `firewall.global_pps` DDoS backstop, shared across all tenants). Nothing throttles you for a brief peak.\n- **Only sustained overuse throttles.** You get an HTTP `429` once your fleet-global request count over the *trailing* 3 minutes exceeds `X * 180` (the sustained rate integrated over the window), or once your 30-day quota is exhausted. \"Fleet-global\" means the count is summed across every edge location, not just the one machine serving you.\n\nBecause the window **rolls**, a burst ages out gradually over the following 3 minutes rather than being forgiven all at once on a boundary. The `Retry-After` on a `429` tells you exactly how long until enough of it has decayed.\n\nThe fields:\n\n- `reqBurstUsed` / `reqBurstCap`: fleet-global requests in the **trailing 3-minute rolling window**, and its allowance. This is exactly the number the edge compares against, so it never disagrees with why you were throttled. The allowance is `X * 180` (the window is `BURST_WINDOW_SECS = 180` seconds). A cap of `0` means **unmetered** (no sustained cap).\n- `req30dUsed` / `req30dCap`: fleet-global requests in the **current 30-day window**, and the 30-day quota. This one still tumbles, because it is a billing period rather than a rate. A quota of `0` means **unmetered**.\n- `sustainedRpsCap`: a convenience `f64` derived from the burst cap, `reqBurstCap / BURST_WINDOW_SECS`. This is your plan's sustained rate `X` in requests/second (`0.0` when unmetered), so you can show \"1000 rps sustained\" without doing the division yourself.\n\nThere is also `nowMs`, the edge wall-clock time (in milliseconds) when the snapshot was taken, so you can show \"as of\" and compute how fresh the numbers are.\n\n## Time series (for graphs)\n\nThe totals above are single numbers. To draw a graph over time, use `Analytics.series(metric, range)`, which returns a `Series`: a list of per-bucket totals from oldest to newest.\n\n```ts\n@get('/requests-24h')\npublic requests24h(ctx: RouteContext): string {\n const s = Analytics.series(MetricId.Requests, AnalyticsRange.H24);\n // s.points -> per-bucket totals, oldest to newest\n // s.bucketSecs -> seconds each bucket covers\n // s.headMs -> end time (ms) of the newest bucket\n // s.ratePerSec(i) -> requests/second for bucket i (points[i] / bucketSecs)\n return `buckets=${s.points.length} bucketSecs=${s.bucketSecs}`;\n}\n```\n\n`AnalyticsRange` picks the window and the resolution. Short ranges use per-minute buckets for a smooth line; the rest use per-hour buckets (kept for 30 days):\n\n| Range | Span | Bucket width |\n| --- | --- | --- |\n| `H1` | 1 hour | 1 minute |\n| `H6` | 6 hours | 1 minute |\n| `H12` | 12 hours | 1 hour |\n| `H24` | 24 hours | 1 hour |\n| `D3` | 3 days | 1 hour |\n| `D7` | 7 days | 1 hour |\n| `D14` | 14 days | 1 hour |\n| `D30` | 30 days | 1 hour |\n\nThe series stores **totals per bucket**, never rates. A rate is derived: `ratePerSec(i)` divides bucket `i` by its width for you, or you can compute `points[i] / bucketSecs` yourself. Most metrics you will chart are counters (`MetricId.Requests`, `MetricId.CacheHits`, and so on).\n\n## Permissions: who can read what\n\n- **Your own site.** `Analytics.self()` and `Analytics.series(...)` always read the site the request landed on. The calling domain is decided by the edge; your code cannot forge it.\n- **Cross-site reads are for `dacely.com` only.** The privileged `dacely.com` domain (the platform dashboard) may read any site with `Analytics.site(domain)`, `Analytics.siteSeries(domain, ...)`, and `Analytics.listSites(...)`. For any other caller, or an unknown domain, `site` and `siteSeries` return `null` and `listSites` returns an empty page. There is no way for a normal site to read another site's numbers.\n\n`Analytics.listSites(cursor, limit)` enumerates site names for the dashboard, one page at a time. It returns a `SiteList` with `sites: string[]` and `hasMore: bool`. When `hasMore` is true, pass the last name back as the next `cursor` to get the following page.\n\n```ts\n// Only meaningful when the calling domain is dacely.com.\nconst page = Analytics.listSites('', 100); // first 100 site names\nfor (let i = 0; i < page.sites.length; i++) {\n const other = Analytics.site(page.sites[i]); // TenantStats | null\n if (other != null) {\n // read other.requests, other.cacheRatio, ...\n }\n}\n```\n\n## Worked example: return everything\n\nThis route reads the full snapshot and maps it into a typed response. Mapping is mechanical: nearly every getter is a `u64` and maps to a `u64` field with no cast; the only `f64` values are the derived `cacheRatio` and `sustainedRpsCap`. The example groups a representative field from each area; add the rest of the getters from the catalog above the same way.\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n/** The shape returned to the client. All fields are u64 except the two derived f64s (cacheRatio, sustainedRpsCap). */\n@data\nexport class UsageReport {\n // requests / L1\n requests: u64 = 0;\n bytesOut: u64 = 0;\n status2xx: u64 = 0;\n status5xx: u64 = 0;\n gasUsed: u64 = 0;\n // database\n dbOps: u64 = 0;\n dbErrors: u64 = 0;\n meanDbLatencyNs: u64 = 0;\n // cache\n cacheHits: u64 = 0;\n cacheMisses: u64 = 0;\n cacheRatio: f64 = 0;\n // live gauges (current level, not a total)\n connectedStreams: u64 = 0;\n committedMemory: u64 = 0;\n // request meters (cap 0 = unmetered)\n reqBurstUsed: u64 = 0; // requests in the current 3-minute sustained-rate bucket\n reqBurstCap: u64 = 0; // per-bucket cap = sustained_rps * 180\n req30dUsed: u64 = 0; // requests in the current 30-day window\n req30dCap: u64 = 0; // 30-day quota\n sustainedRpsCap: f64 = 0; // derived: reqBurstCap / 180 (the plan's sustained rps)\n // when the snapshot was read (edge ms)\n nowMs: u64 = 0;\n}\n\n@rest('usage')\nclass Usage {\n @get('/')\n public report(ctx: RouteContext): UsageReport {\n const s = Analytics.self();\n const out = new UsageReport();\n\n out.requests = s.requests;\n out.bytesOut = s.bytesOutL1;\n out.status2xx = s.status2xx;\n out.status5xx = s.status5xx;\n out.gasUsed = s.gasUsed;\n\n out.dbOps = s.dbOps;\n out.dbErrors = s.dbErrors;\n out.meanDbLatencyNs = s.meanDbLatencyNs;\n\n out.cacheHits = s.cacheHits;\n out.cacheMisses = s.cacheMisses;\n out.cacheRatio = s.cacheRatio;\n\n out.connectedStreams = s.connectedStreams;\n out.committedMemory = s.committedMemory;\n\n out.reqBurstUsed = s.reqBurstUsed;\n out.reqBurstCap = s.reqBurstCap;\n out.req30dUsed = s.req30dUsed;\n out.req30dCap = s.req30dCap;\n out.sustainedRpsCap = s.sustainedRpsCap;\n\n out.nowMs = s.nowMs;\n return out;\n }\n}\n```\n\nBecause this is a `@data` return type, the client gets a fully typed result from `Server.REST.usage.report()`. See [structured data types](../backend/data.md) for how `@data` works.\n\n## Local dev behavior\n\nUnder `toiljs dev`, the real per-domain metering does not exist (there is only one machine and no fleet), so the dev server returns a **fixed sample** `TenantStats` and a gentle synthetic ramp for `series(...)`. That lets you build and style a dashboard locally. Dev is also permissive: it returns sample data for any domain, because the real `dacely.com`-only check is enforced on the edge. Your code is unchanged; only the numbers differ.\n\n## Gotchas and limits\n\n- **Totals never reset.** The counter getters are lifetime running totals. To see \"requests in the last hour\", use `series(...)` and sum the buckets, not the totals.\n- **Values are `u64`.** Map them into `u64` fields with no casts. The non-integer fields are `cacheRatio` and `sustainedRpsCap` (and `Series.ratePerSec`), which are `f64`.\n- **Spikes are free; only sustained overuse throttles.** The request meters follow a sustained-rate model: you may burst up to the box's hardware ceiling, and throttling (`429`) starts only when the current 3-minute bucket passes `sustainedRpsCap * 180`, or when the 30-day quota runs out. A cap or quota of `0` means unmetered.\n- **Not per-user analytics.** These are infrastructure counters per domain. There is no way to record a custom event or attribute a number to a specific user.\n- **Cross-site reads need `dacely.com`.** `Analytics.site(...)` returns `null` for everyone else. Do not build a feature that reads another site's stats.\n- **Snapshots, not live streams.** Each call reads the current values once. `nowMs` tells you when.\n\n## Related\n\n- [Caching](./caching.md), the source of `cacheHits`, `cacheMisses`, and `cacheRatio`.\n- [ToilDB database](../database/README.md), the source of the `db*` counters and where these counters are stored.\n- [Structured data types](../backend/data.md), for the `@data` return type in the worked example.\n- [Compute tiers](../concepts/tiers.md), for what \"L1\", \"streams\", and \"daemons\" mean in the catalog.\n",
63
63
  "services/caching.md": "# Caching\n\n**Caching lets the edge keep a copy of a response and hand it back instantly, without re-running your code.** You opt in per route, either with the `@cache` decorator or by calling `Response.cache(...)`.\n\n## What \"cache\" means here\n\nA **cache** is a fast, temporary store of a previous answer. When a request comes in, the edge can check: \"have I already computed this exact response recently?\" If yes, that is a **cache hit**: it returns the saved copy in microseconds and never wakes up your WebAssembly program. If no, that is a **cache miss**: it runs your handler, sends the result, and (if you asked it to) saves a copy for next time.\n\nThere are two places a response can be cached:\n\n- **The edge cache** is shared: one saved copy on an edge server answers many users. This is where the big speedups come from.\n- **The browser cache** is private to one user's browser. You control it with a standard `Cache-Control` header.\n\nYou can use one or both.\n\n## When to cache (and when not)\n\nCache a response only when it is the **same for everyone** and safe to reuse for a little while:\n\n- Good: a public leaderboard, a product catalog, a blog index, an exchange-rate snapshot, a \"server status\" endpoint.\n- Bad: anything personalized (a user's dashboard, their cart, \"hello, Alice\"). If you cached that, one user could be served another user's data. The edge has strong safety rails against this (below), but the simplest rule is: **do not cache per-user data on the shared edge.**\n\nCaching is **always opt-in**. A response you do not mark is never stored. There is no \"cache every GET\" mode, because the edge cannot tell a personalized response from a public one on its own.\n\n## How: the `@cache` decorator\n\nA **decorator** is an annotation you put right above a route method. `@cache` tells the edge how long it may reuse the response. The numbers are positional:\n\n```ts\nimport { Response } from 'toiljs/server/runtime';\n\n@rest('leaderboard')\nclass Leaderboard {\n @cache(60) // 60 MINUTES at the edge\n @get('/')\n public top(): Response {\n // ...expensive query...\n return Response.json(standings);\n }\n}\n```\n\nThe full form is `@cache(edgeTtlMinutes, browserTtlSeconds?, privateScope?, allowAuth?)`:\n\n```ts\n@cache(60) // 60 minutes at the edge, browser does not cache\n@cache(60, 300) // + tell the browser to cache for 5 minutes (300s)\n@cache(60, 300, true) // + mark it \"private\": browser only, never the shared edge\n@cache(60, 300, true, true) // + allow caching even for logged-in requests (see rails)\n```\n\n**TTL** means \"time to live\": how long a saved copy stays fresh before the edge throws it away and recomputes. Notice the units differ on purpose: the edge TTL is in **minutes**, the browser TTL is in **seconds**.\n\nEvery argument must be a plain number or `true`/`false` literal. If you pass something computed (a variable, a function call), the decorator safely degrades to \"not cached\" instead of misbehaving.\n\n## How: `Response.cache(...)`\n\n`@cache` is just a shorthand. You can do the same thing by hand on any `Response`, which is handy when you decide at runtime:\n\n```ts\npublic cache(\n edgeTtlMinutes: u16,\n browserTtlSeconds: u32 = 0,\n privateScope: bool = false,\n allowAuth: bool = false,\n): Response\n```\n\n```ts\nreturn Response.json(body).cache(60, 300);\n```\n\n`cache(...)` returns the same `Response`, so it chains. There is also a shorthand for \"edge only, no browser caching\":\n\n```ts\nreturn Response.bytes(blob).cacheFor(5); // edge-cache 5 minutes\n```\n\nUnder the hood, both set one header, `Dacely-Cache-Control`, that the edge reads and then strips (it never reaches the client).\n\n## The parameters, in detail\n\n| Parameter | What it does |\n| --- | --- |\n| `edgeTtlMinutes` | How long the shared edge may serve the saved copy. Clamped to a 24-hour maximum, no matter what you pass. `0` means no edge caching. |\n| `browserTtlSeconds` | The `max-age` sent to the browser. `0` (default) means the browser is told not to cache. |\n| `privateScope` | Marks the response `private`: it may only live in the browser's own cache, never the shared edge or a CDN. |\n| `allowAuth` | Permits caching a response to a logged-in request. Off by default (see safety rails). |\n\n## A hit vs a miss, visually\n\n```mermaid\nflowchart TD\n R[\"Request arrives<br/>at the edge\"] --> Q{\"Saved copy<br/>still fresh?\"}\n Q -->|Yes: cache HIT| H[\"Return the saved response<br/>(microseconds, no code runs)\"]\n Q -->|No: cache MISS| M[\"Run your WebAssembly handler\"]\n M --> D{\"Marked cacheable<br/>and eligible?\"}\n D -->|Yes| S[\"Save a copy for the TTL,<br/>then return it\"]\n D -->|No| X[\"Return it, save nothing\"]\n```\n\nEvery response the edge sends carries a `Dacely-Cache` header so you can see what happened while debugging:\n\n| `Dacely-Cache` value | Meaning |\n| --- | --- |\n| `HIT` | Served from the cache; your code did not run. |\n| `MISS` | Your code ran, and the response was saved (the next identical request should `HIT`). |\n| `DYNAMIC` | Your code ran, and the response was not cached (no directive, or not eligible). |\n| `BYPASS` | A fallback path (for example the compute layer was momentarily unavailable); not a cached answer. |\n\n## Safety rails (why cross-user leaks cannot happen)\n\nThe edge refuses to store certain responses even if you asked it to. These rules exist because a shared cache is exactly where one user's data could accidentally leak to another:\n\n- **5xx responses are never cached.** A server error is temporary; caching a `500` would keep serving the failure for the whole TTL. (2xx, 3xx, and 4xx are cacheable: a redirect or a `404`/`410` is a predictable function of the request.)\n- **A response with a `Set-Cookie` is never cached.** A cookie is per-user state.\n- **A response to a logged-in request is not cached** unless you pass `allowAuth = true`. This stops one user's personalized response from being handed to someone else. Even with `allowAuth = true`, such an entry is only ever served back to other **authenticated** requests, never to an anonymous one (which would skip the auth check entirely).\n- **The edge TTL is clamped to 24 hours.**\n\nThe cache is also keyed precisely: an entry is identified by the **host, method, path, and the exact request body**. So a `POST` with body `A` never returns the copy saved for body `B`, and a `GET` never returns a `POST`'s copy.\n\nBecause auth checks and body decoding run **before** the cache directive is applied, an unauthorized request is rejected with `401` before anything is stored, and a cached copy only ever comes from a handler that actually ran successfully.\n\n## ETag, Last-Modified, and 304 (static files)\n\nThe `@cache` decorator above is for **dynamic** responses (ones your code computes). Your project's **static files** (the built HTML, JS, CSS, and images) get a second, automatic layer of caching that you do not configure. It is worth understanding because it is what makes repeat page loads cheap.\n\nEvery static file the edge serves carries three headers:\n\n- **`ETag`**: a short fingerprint of the file's contents (a \"version tag\"). toiljs uses a *weak* ETag (it starts with `W/`), which survives compression by a CDN in front of the edge.\n- **`Last-Modified`**: when the file last changed.\n- **`Cache-Control`**: content-hashed assets (files whose name contains their content hash, like `app-CfvHW67Y.js`) get `immutable` with a one-year max-age, because the URL itself changes whenever the content does. Everything else (HTML, non-hashed assets) gets `must-revalidate`, so the browser always checks freshness.\n\n**Revalidation** is the trick. Instead of re-downloading a file it already has, a browser asks \"has this changed?\" by echoing the tag back in an `If-None-Match` header (or the date in `If-Modified-Since`). If the file is unchanged, the edge replies **`304 Not Modified`** with an empty body, and the browser reuses its copy. A `304` is tiny and fast.\n\n```mermaid\nsequenceDiagram\n participant B as Browser\n participant E as Dacely edge\n B->>E: GET /app.js<br/>If-None-Match: W/\"1a2b-c3d4\"\n alt file unchanged\n E-->>B: 304 Not Modified<br/>(no body)\n else file changed\n E-->>B: 200 OK<br/>(new bytes + new ETag)\n end\n```\n\nThe precedence follows the HTTP spec (RFC 7232): if the request has `If-None-Match`, that decides it; only when it is absent does `If-Modified-Since` apply. You get all of this for free; there is nothing to turn on.\n\n## Where cached bodies live (memory bounds)\n\nYou do not need this to use caching, but it explains the limits you may hit.\n\nThe edge response cache is bounded and hard-capped so it can never exhaust a node's memory:\n\n- **RAM tier**: small, short-lived responses live in memory, within a bounded budget (on the order of ~128 MB) plus a cap on the number of entries. A single response over ~256 KB does not go in RAM.\n- **Disk tier (optional)**: when the operator enables an on-disk spill directory, a **big** response (over ~256 KB) or a **long-lived** one (TTL of 10 minutes or more) is written to disk and served back with near-zero RAM, the same way static files are. If disk spill is not enabled, a big response is simply not cached (you will see `Dacely-Cache: DYNAMIC`).\n\nExpired entries are dropped on read (a stale entry is treated as a miss) and reclaimed when the cache needs room. Nothing survives a process restart.\n\n## Gotchas\n\n- **Units differ.** Edge TTL is in minutes; browser TTL is in seconds. `@cache(60)` is one hour at the edge, not one minute.\n- **Do not cache personalized data on the shared edge.** If a response depends on who is asking, either do not cache it, or use `privateScope` so it is browser-only.\n- **A `Set-Cookie` disables edge caching for that response.** If you set a cookie, that response will not be edge-cached, by design.\n- **Very large or slow-to-change bodies may not be cached** unless disk spill is enabled on the node. Check the `Dacely-Cache` header if a response you expected to cache shows `DYNAMIC`.\n- **Nothing persists across restarts.** The cache is a speed optimization, never a source of truth.\n\n## Related\n\n- [Rate limiting](./ratelimit.md): protect a route before it runs.\n- [Analytics](./analytics.md): see your cache hit ratio per site.\n- [Cookies](./cookies.md): why a `Set-Cookie` opts a response out of edge caching.\n- [Auth, sessions, and `@user`](../auth/usage.md): why logged-in responses are not cached by default.\n- [Every decorator](../concepts/decorators.md).\n",
64
64
  "services/cookies.md": "# Cookies\n\n**Cookies let you store a small piece of state in the user's browser and read it back on the next request.** toiljs ships a complete cookie layer: read incoming cookies, set them on a response, and (optionally) sign or encrypt their values so they cannot be tampered with.\n\n## What a cookie is\n\nA **cookie** is a tiny named value the browser holds for your site. The browser sends it back on every request to your site (in a `Cookie` request header), and you can change or add cookies by putting a `Set-Cookie` header on your response. Cookies are how a site remembers things between requests: a theme preference, a feature flag, a session id.\n\nTwo directions to keep straight:\n\n- **Reading** happens on the `Request` (the browser sent its cookies to you).\n- **Writing** happens on the `Response` (you tell the browser to store or clear a cookie).\n\n```mermaid\nsequenceDiagram\n participant B as Browser\n participant S as Your handler\n B->>S: Request<br/>Cookie: theme=dark\n Note over S: req.cookie('theme') returns \"dark\"\n S-->>B: Response<br/>Set-Cookie: theme=light\n Note over B: browser stores the new cookie<br/>and sends it next time\n```\n\nThe cookie types (`Cookie`, `Cookies`, `CookieMap`, `SecureCookies`, and the `SameSite` / `CookieEncoding` / `CookiePrefix` enums) are **ambient globals**: use them with no import, like `crypto`. They are also exported from `toiljs/server/runtime` if you prefer an explicit import.\n\n## Reading incoming cookies\n\nOn the `Request`, two methods cover almost everything:\n\n```ts\nconst theme = req.cookie('theme'); // one value: string | null\nconst jar = req.cookies(); // all of them, parsed once and cached\njar.get('theme'); // \"dark\", or null\njar.has('theme'); // true / false\n```\n\n`req.cookies()` returns a `CookieMap`, an ordered name-to-value view. It is parsed once per request and cached, so calling it repeatedly is cheap.\n\n## Setting a cookie on a response\n\nBuild a cookie with the fluent `Cookie` builder, then attach it with `setCookie`. Every setter returns the cookie, so calls chain:\n\n```ts\nimport { Response } from 'toiljs/server/runtime';\n\nreturn Response.json('{\"ok\":true}').setCookie(\n Cookie.create('theme', 'dark')\n .path('/')\n .maxAge(60 * 60 * 24 * 365) // one year, in seconds\n .sameSite(SameSite.Lax)\n .secure(),\n);\n```\n\nShorthands for the common cases:\n\n```ts\nresp.setCookieKV('theme', 'dark'); // name=value, no attributes\nresp.clearCookie('theme'); // delete it (empty value, expired)\n```\n\nEach `setCookie` adds its own `Set-Cookie` header; cookies are never folded into one header.\n\n### The attributes you will actually use\n\n| Method | Sets | What it means |\n| --- | --- | --- |\n| `path('/')` | `Path` | Which URL paths the cookie applies to. `/` means the whole site. |\n| `maxAge(seconds)` | `Max-Age` | How long the browser keeps it. `0` or negative deletes it now. |\n| `secure()` | `Secure` | Only send over HTTPS. Use it for anything that matters. |\n| `httpOnly()` | `HttpOnly` | Hide it from JavaScript in the browser (blocks theft via XSS). Use it for session cookies. |\n| `sameSite(SameSite.Lax)` | `SameSite` | Controls whether the cookie is sent on cross-site requests. `Lax` is a good default; `Strict` is tighter; `None` allows cross-site (and forces `Secure`). |\n\nYou rarely need the rest, but they exist: `domain`, `expires` (an absolute time instead of a duration), `partitioned` (CHIPS), `priority`, and `extension` for anything custom.\n\n## The `__Host-` and `__Secure-` prefixes\n\nTwo special name prefixes give the browser extra guarantees. They are not magic strings you invent; browsers recognize them and **refuse** to accept a cookie that carries the prefix without meeting its rules.\n\n- **`__Secure-`**: the cookie must be `Secure` (HTTPS only).\n- **`__Host-`**: the cookie must be `Secure`, have `Path=/`, and have **no** `Domain`. This locks the cookie to exactly your host, so a sibling subdomain cannot set or read it. It is the strongest option and the right choice for session cookies.\n\nYou do not spell the prefix yourself. Two helpers apply it and enforce the rules for you:\n\n```ts\nCookie.create('sid', 'abc123')\n .httpOnly()\n .sameSite(SameSite.Lax)\n .maxAge(3600)\n .asHostPrefixed(); // prepends __Host-, forces Secure + Path=/ + no Domain\n```\n\n`asSecurePrefixed()` is the lighter version (prepends `__Secure-` and forces `Secure`).\n\n> Under `toiljs dev`, browsers treat `http://localhost` as a secure context, so `Secure` and `__Host-` cookies work over plain HTTP locally. You do not need HTTPS to test them.\n\n## Worked example: a theme preference cookie\n\nA tiny handler that reads a `theme` cookie and lets the user flip it. This is the canonical \"remember a preference\" pattern: a plain, non-secret value.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('prefs')\nclass Prefs {\n // GET /prefs/theme -> current theme (defaults to \"light\")\n @get('/theme')\n read(ctx: RouteContext): Response {\n const theme = ctx.request.cookie('theme');\n return Response.text((theme != null ? theme : 'light') + '\\n');\n }\n\n // POST /prefs/theme/dark -> remember \"dark\" for a year\n @post('/theme/dark')\n setDark(ctx: RouteContext): Response {\n return Response.text('saved\\n').setCookie(\n Cookie.create('theme', 'dark')\n .path('/')\n .maxAge(60 * 60 * 24 * 365)\n .sameSite(SameSite.Lax)\n .secure(),\n );\n }\n}\n```\n\nA preference like this does not need to be secret or tamper-proof (the worst a user can do is change their own theme), so a plain cookie is fine. When the value **does** matter, reach for `SecureCookies`.\n\n## Secure cookies: signing and encryption\n\nSometimes a cookie value must not be forged or read by the user. `SecureCookies` covers both, built on the `crypto` global (no extra setup):\n\n- **Signed** (`SecureCookies.signed(key)`): the value stays readable but is stamped with a signature (HMAC-SHA256) bound to the cookie's name. The user can see it but cannot change it or move it to another cookie without the signature failing. Use this for a value the client may read but must not tamper with.\n- **Encrypted** (`SecureCookies.encrypted(key)`): the value is scrambled (AES-GCM) so the user cannot read **or** change it. Use this for something confidential.\n\nThe `key` is raw secret bytes you supply (load it from a secret via [`Environment.getSecure`](./environment.md), never hard-code it):\n\n```ts\n// In a real app, load this from a secret, do not inline it.\nconst key = Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));\n\n// Signed: readable but tamper-proof.\nconst signer = SecureCookies.signed(key);\nconst sealed = signer.sign('session', 'user-42');\nconst user = signer.unsign('session', sealed); // \"user-42\", or null if tampered\n\n// Encrypted: unreadable and authenticated.\nconst box = SecureCookies.encrypted(key);\nresp.setCookie(box.seal(Cookie.create('secret', 'top-secret').httpOnly()));\nconst secret = box.open(req.cookies(), 'secret'); // \"top-secret\", or null\n```\n\nTwo important safety properties:\n\n- **Verification never crashes.** `unsign` and `decrypt` return `null` on a tampered, truncated, or wrong-key value; they never throw. That makes them safe to call directly on attacker-controlled input.\n- **Values are bound to the cookie name.** A sealed value made for cookie `a` will not verify or decrypt under cookie `b`.\n\nFor HMAC keys, use 32 or more bytes. For AES, the key must be exactly 16 or 32 bytes (a wrong length is rejected immediately). You can rotate keys by sealing with a new key while still accepting an old one:\n\n```ts\nconst signer = SecureCookies.signed(newKey).addKey(oldKey); // seal with new, open with either\n```\n\n## Encoding is not encryption\n\nOne common mix-up. **Encoding** (the `CookieEncoding` on a `Cookie`, default percent-encoding) only makes a value safe to put on the wire; anyone can reverse it. **Signing / encryption** (`SecureCookies`) is the cryptographic protection and needs a secret key. If you want a value the user cannot forge, you need `SecureCookies`, not a fancier encoding.\n\n## Relationship to auth session cookies\n\nYou do not manage login cookies by hand. The [auth system](../auth/usage.md) sets and reads its own hardened, `__Host-` prefixed, signed session cookie for you, and enforces access with the `@auth` decorator. This page is for **your own** cookies (preferences, flags, small bits of state). If you find yourself building a login cookie from scratch, use auth instead; it already does the hard, security-sensitive parts correctly.\n\n## Advanced reference: `Cookie` and `Cookies` helpers\n\nMost handlers only need the builders above. These extra members are here for lower-level work: validating a cookie before you send it, parsing a `Set-Cookie` header back into a `Cookie` (for a proxy or a test), or controlling the exact wire encoding.\n\n### More `Cookie` methods\n\n| Method | Returns | What it does |\n| --- | --- | --- |\n| `validate()` | `CookieValidation` | Check the cookie against RFC 6265bis (name is a token, name+value within the 4096-byte cap, `Path` form, prefix rules, `SameSite=None` / `Partitioned` imply `Secure`, the 400-day lifetime cap) **without** sending it. Never throws. |\n| `serialize(strict)` | `string` | Build the `Set-Cookie` value. Lenient by default (always emits a best-effort cookie); pass `serialize(true)` to **throw** on a hard violation instead. `setCookie(...)` calls this for you. |\n| `withEncoding(enc)` | `Cookie` | Choose how the value goes on the wire: `CookieEncoding.Percent` (default), `.Raw`, or `.Base64Url`. Chains like the other setters. |\n| `detectedPrefix()` | `CookiePrefix` | Report which special prefix the name carries (`CookiePrefix.Host`, `.Secure`, or `.None`), detected case-insensitively. |\n| `encodedValue()` | `string` | The value after the chosen encoding is applied, exactly as it will appear on the wire. |\n| `expiresRaw(date)` | `Cookie` | Set `Expires` to a verbatim date string (an escape hatch; it is **not** validated, unlike `expires(epochSeconds)`). |\n\n`validate()` returns a `CookieValidation`, a small result object:\n\n- `valid: bool` is `true` when there were no problems.\n- `errors: Array<string>` holds one human-readable message per problem (empty when valid).\n\n```ts\n// A __Host- cookie must be Secure with Path=/; here we forgot both.\nconst c = Cookie.create('__Host-sid', 'abc');\nconst check = c.validate();\nif (!check.valid) {\n // check.errors includes \"__Host- prefix requires the Secure attribute\"\n // (asHostPrefixed() would have set those attributes for you)\n}\n```\n\n### `Cookies` static helpers\n\n`Cookies` is the read side, plus a couple of codec shortcuts. Every method is static, so call them as `Cookies.xxx(...)`.\n\n| Call | Returns | What it does |\n| --- | --- | --- |\n| `Cookies.parse(header)` | `CookieMap` | Parse a request `Cookie` header (`a=1; b=2`) into a name-to-value map. Values are percent-decoded; malformed pairs are skipped, never thrown. |\n| `Cookies.get(header, name)` | `string \\| null` | Shorthand: parse `header` and return one value (or `null`). |\n| `Cookies.serialize(name, value)` | `string` | One-shot `Set-Cookie` value for `name=value` with no attributes. For attributes, build a `Cookie` and call `serialize()`. |\n| `Cookies.parseSetCookie(header)` | `Cookie` | Parse a `Set-Cookie` field value back into a `Cookie` (handy for proxies or tests). The value is kept verbatim (`CookieEncoding.Raw`) so re-serializing reproduces the original. |\n| `Cookies.encodeValue(raw)` | `string` | Percent-encode a value the way the default `Cookie` encoding would. |\n| `Cookies.decodeValue(enc)` | `string` | Percent-decode a value (the inverse of `encodeValue`). |\n\n```ts\n// Round-trip a Set-Cookie header (for example, inspecting an upstream response in a proxy):\nconst cookie = Cookies.parseSetCookie('sid=abc123; Path=/; HttpOnly; SameSite=Lax');\ncookie.name; // \"sid\"\ncookie.serialize(); // \"sid=abc123; Path=/; SameSite=Lax; HttpOnly\"\n```\n\n## Gotchas\n\n- **Read on the `Request`, write on the `Response`.** Setting a cookie does not change what `req.cookie(...)` returns for the current request; it takes effect on the browser's next request.\n- **`maxAge` is in seconds.** `maxAge(3600)` is one hour, not one millisecond.\n- **To delete a cookie, the `path` (and `domain`) must match** the ones you set it with. Use `clearCookie(name, path, domain)`.\n- **A `Set-Cookie` opts a response out of edge caching.** By design, a response that sets a cookie is never edge-cached, because a cookie is per-user state. See [Caching](./caching.md).\n- **Do not put secrets in a plain cookie value.** Use `SecureCookies.encrypted(...)`, and load the key from a [secret](./environment.md).\n\n## Related\n\n- [Auth, sessions, and `@user`](../auth/usage.md): the built-in, hardened session cookie.\n- [Crypto](./crypto.md): the primitives under `SecureCookies`.\n- [Environment and secrets](./environment.md): where to store your signing / encryption key.\n- [Caching](./caching.md): why setting a cookie disables edge caching.\n",
65
65
  "services/crypto.md": "# Crypto\n\nA small, safe cryptography toolkit (hashing, random bytes, HMAC, symmetric encryption, key derivation, and signatures) available with no import through the ambient `crypto` global.\n\nReach for `crypto` to fingerprint a value (hashing), generate an unguessable token or ID (random bytes), prove a value has not been tampered with (HMAC or a signature), or encrypt data you store yourself (AES). Skip it for **login passwords**: use [auth](../auth/README.md), which is purpose-built and does the hard parts for you. `crypto` is also the engine under [signed cookies](./cookies.md) and auth, so most apps use it indirectly without ever calling it.\n\n## The shape of the API\n\n`crypto` mirrors the browser Web Crypto API, with one big difference: **there are no Promises**. ToilScript (the language your backend is written in) has no `async`, so every call returns its result **directly**, right away.\n\n```ts\nconst digest = crypto.sha256Text('hello'); // Uint8Array, no await\nconst id = crypto.randomUUID(); // string, no await\n```\n\nThere are two layers:\n\n- **`crypto.*`**: ergonomic one-call helpers (hash this string, give me a UUID, HMAC these bytes). Start here.\n- **`crypto.subtle`**: the full primitive surface (import a key, encrypt, sign, derive bits) for when you need more control.\n\nBoth are ambient globals (no import). The small helper classes and the `ALG_*` / `FMT_*` / `USAGE_*` / `CURVE_*` constants you pass to `subtle` are imported from the `'crypto'` module.\n\n## Quick helpers (`crypto.*`)\n\nEvery helper is synchronous and returns its value directly.\n\n| Helper | Signature | What it does |\n| --- | --- | --- |\n| `getRandomValues` | `(array: Uint8Array): void` | Fill the array with cryptographically strong random bytes. |\n| `randomUUID` | `(): string` | A random RFC 4122 v4 UUID string. |\n| `sha1` / `sha256` / `sha384` / `sha512` | `(data: Uint8Array): Uint8Array` | Hash raw bytes. |\n| `sha1Text` … `sha512Text` | `(s: string): Uint8Array` | UTF-8 encode the string, then hash. |\n| `hmacSha256` | `(key: Uint8Array, msg: Uint8Array): Uint8Array` | Keyed fingerprint (HMAC-SHA-256) of bytes. |\n| `hmacSha256Text` | `(key: Uint8Array, msg: string): Uint8Array` | HMAC-SHA-256 over a string. |\n| `toHex` | `(bytes: Uint8Array): string` | Lowercase hex string (for display or storage). |\n| `subtle` | `SubtleCrypto` | The full primitive surface (below). |\n\n**Hashing** turns any input into a fixed-size fingerprint. The same input always gives the same output, and you cannot work backward from the fingerprint to the input. Use it to compare or index a value without storing the original (for example, keying a cache by the hash of a URL).\n\n**Random bytes** come from a CSPRNG (a cryptographically secure random generator), which means the output is unpredictable and safe for tokens, session IDs, and IVs. Never use `Math.random()` for anything security-related.\n\n**HMAC** is a fingerprint computed *with a secret key*. Anyone can hash a value, but only someone who holds the key can produce the correct HMAC, so it proves a value came from you and was not altered. This is exactly what [`TwoFactor`](./email.md) tokens and [signed cookies](./cookies.md) use.\n\n## Worked example: hash a value and generate a token\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n@rest('demo')\nclass Demo {\n @get('/')\n public example(ctx: RouteContext): string {\n // Hash a value to a hex fingerprint (safe to log or use as a key).\n const digest = crypto.sha256Text('alice@example.com');\n const fingerprint = crypto.toHex(digest); // 64 hex chars\n\n // Generate a 128-bit unguessable token as hex.\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n const token = crypto.toHex(bytes); // 32 hex chars\n\n // A ready-made unique id.\n const id = crypto.randomUUID();\n\n return `fp=${fingerprint} token=${token} id=${id}`;\n }\n}\n```\n\nTo fingerprint a value *with a secret* (so only your server can produce or check it), use HMAC:\n\n```ts\nconst key = Uint8Array.wrap(String.UTF8.encode(Environment.getSecure('SIGNING_KEY')!));\nconst mac = crypto.hmacSha256Text(key, 'order:42');\nconst macHex = crypto.toHex(mac);\n```\n\n## The primitive surface (`crypto.subtle`)\n\nUse `subtle` when the helpers are not enough: symmetric encryption, signatures, or key derivation. Every method is synchronous.\n\n| Method | Signature |\n| --- | --- |\n| `digest` | `digest(algorithm: i32, data: Uint8Array): Uint8Array` |\n| `importKey` | `importKey(format: i32, keyData: Uint8Array, algorithm: AlgorithmParams, extractable: bool, usages: i32): CryptoKey` |\n| `exportKey` | `exportKey(format: i32, key: CryptoKey): Uint8Array` |\n| `encrypt` | `encrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `decrypt` | `decrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `sign` | `sign(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `verify` | `verify(algorithm: AlgorithmParams, key: CryptoKey, signature: Uint8Array, data: Uint8Array): bool` |\n| `deriveBits` | `deriveBits(algorithm: AlgorithmParams, baseKey: CryptoKey, length: i32): Uint8Array` |\n| `deriveKey` | `deriveKey(algorithm, baseKey, lengthBits, derivedKeyAlgorithm, extractable, usages): CryptoKey` |\n\nTwo things differ from the browser API, both because of the missing-Promise design:\n\n- **`algorithm` and `format` are integer constants, not strings.** You pass `ALG_SHA_256` (not `\"SHA-256\"`) and `FMT_RAW` (not `\"raw\"`). The constants are listed below.\n- **`verify` returns a `bool`.** A signature mismatch returns `false` (it does not throw). A real error, like an invalid key, does throw.\n\n### Keys are per-request handles\n\nYou never hold raw key bytes in your code for long. `importKey` gives you back a **`CryptoKey`**, which is an opaque handle into a host-side keystore. That keystore is **wiped when the request ends**, so a `CryptoKey` is valid only within the request that created it. Never cache one across requests; import it again each time.\n\n```mermaid\nsequenceDiagram\n participant G as Your handler\n participant H as Host keystore (per request)\n G->>H: importKey(...) -> a CryptoKey handle\n G->>H: sign(params, key, data)\n H-->>G: signature bytes\n Note over H: the keystore is wiped when the request ends\n```\n\n### Algorithm parameter classes\n\nEach algorithm takes a small parameters object you build and pass in. Import the class you need from `'crypto'`:\n\n```ts\nimport { AesGcmParams, HmacImportParams, ALG_SHA_256, USAGE_ENCRYPT, USAGE_DECRYPT } from 'crypto';\n```\n\n| Class | Constructor | Used for |\n| --- | --- | --- |\n| `AesGcmParams` | `(iv, additionalData?, tagLength = 128)` | AES-GCM encrypt/decrypt |\n| `AesCbcParams` | `(iv)` | AES-CBC |\n| `AesCtrParams` | `(counter, length = 128)` | AES-CTR |\n| `HmacImportParams` | `(hash)` | importing an HMAC key |\n| `HmacParams` | `()` | HMAC sign/verify (hash comes from the key) |\n| `Pbkdf2Params` | `(hash, salt, iterations)` | deriving a key from a password |\n| `HkdfParams` | `(hash, salt, info?)` | deriving a key from key material |\n| `EcdsaParams` | `(hash)` | ECDSA sign/verify |\n| `EcKeyImportParams` | `(alg, namedCurve)` | importing an EC key |\n| `Ed25519Params` | `()` | Ed25519 sign/verify |\n| `X25519ImportParams` | `()` | importing an X25519 key |\n| `EcdhParams` | `(alg, publicKeyHandle)` | ECDH / X25519 key agreement |\n\n### Constants\n\n- **Hashes and algorithms:** `ALG_SHA_1`, `ALG_SHA_256`, `ALG_SHA_384`, `ALG_SHA_512`, `ALG_SHA3_256`, `ALG_SHA3_384`, `ALG_SHA3_512`, `ALG_AES_GCM`, `ALG_AES_CBC`, `ALG_AES_CTR`, `ALG_AES_KW`, `ALG_HMAC`, `ALG_ECDSA`, `ALG_ED25519`, `ALG_ECDH`, `ALG_X25519`, `ALG_HKDF`, `ALG_PBKDF2`.\n- **Key formats:** `FMT_RAW`, `FMT_PKCS8`, `FMT_SPKI`. (`FMT_JWK` is rejected.)\n- **Key usages (a bitmask, OR them together):** `USAGE_ENCRYPT`, `USAGE_DECRYPT`, `USAGE_SIGN`, `USAGE_VERIFY`, `USAGE_DERIVE_KEY`, `USAGE_DERIVE_BITS`, `USAGE_WRAP_KEY`, `USAGE_UNWRAP_KEY`.\n- **Named curves:** `CURVE_P256`, `CURVE_P384`. (`CURVE_P521` is not supported.)\n\nThe `digest` selector also accepts the SHA3 constants, so `crypto.subtle.digest(ALG_SHA3_256, data)` works even though there is no `sha3` quick helper.\n\n### `CryptoKey`\n\nA `CryptoKey` is a handle plus metadata: `handle: i32`, `type: string` (`\"secret\"`, `\"public\"`, or `\"private\"`), `extractable: bool`, `algorithm: i32`, and `usages: i32`, with `algorithmName()` and `hasUsage(u)` helpers. Remember it is only valid within the current request.\n\n## Example: AES-256-GCM encrypt and decrypt\n\nAES-GCM is authenticated encryption: it both hides the data and detects tampering. It needs a 32-byte key and a fresh 12-byte IV (a \"number used once\"). **Never reuse an IV with the same key**; generate a new one every time.\n\n```ts\nimport { AesGcmParams, ALG_AES_GCM, FMT_RAW, USAGE_ENCRYPT, USAGE_DECRYPT } from 'crypto';\n\n// 32-byte key and a fresh 12-byte IV.\nconst rawKey = new Uint8Array(32); crypto.getRandomValues(rawKey);\nconst iv = new Uint8Array(12); crypto.getRandomValues(iv);\n\n// Import the key once; grant it both usages so it can round-trip.\nconst key = crypto.subtle.importKey(\n FMT_RAW, rawKey, new AesGcmParams(iv), false, USAGE_ENCRYPT | USAGE_DECRYPT,\n);\n\nconst plaintext = Uint8Array.wrap(String.UTF8.encode('secret note'));\nconst ciphertext = crypto.subtle.encrypt(new AesGcmParams(iv), key, plaintext);\nconst recovered = crypto.subtle.decrypt(new AesGcmParams(iv), key, ciphertext);\n// recovered == plaintext\n```\n\nStore the `iv` next to the ciphertext (it is not secret); you need the same IV to decrypt.\n\n## Example: HMAC with `subtle`\n\nThe quick `crypto.hmacSha256` helper does this in one line, but here is the explicit form, which mirrors how [`TwoFactor`](./email.md) signs its tokens:\n\n```ts\nimport { HmacImportParams, HmacParams, ALG_SHA_256, FMT_RAW, USAGE_SIGN, USAGE_VERIFY } from 'crypto';\n\nconst key = crypto.subtle.importKey(\n FMT_RAW, rawKeyBytes, new HmacImportParams(ALG_SHA_256), false, USAGE_SIGN | USAGE_VERIFY,\n);\n\nconst mac = crypto.subtle.sign(new HmacParams(), key, message);\nconst ok: bool = crypto.subtle.verify(new HmacParams(), key, mac, message); // true\n\n// verify compares in constant time and returns false on a mismatch (it does not throw).\n```\n\n## Limitations\n\n- **No Promises.** Every call is synchronous.\n- **No RSA.** It was dropped because the only pure-Rust implementation had an unfixable timing weakness. Use ECDSA or Ed25519 for signatures.\n- **No JWK key format.** Use `FMT_RAW`, `FMT_PKCS8`, or `FMT_SPKI`.\n- **No on-host key generation.** Keys are always **imported**, never generated on the server. Generate them elsewhere and import the bytes.\n- **No P-521.** `CURVE_P256` and `CURVE_P384` are supported.\n- **Keys do not survive the request.** A `CryptoKey` is a per-request handle; re-import each request.\n- **Every call is metered.** Crypto operations cost compute (charged up front from the sizes involved), so an over-budget call fails cleanly instead of burning CPU.\n\n### Post-quantum signature verify\n\nAuth's login uses a post-quantum signature scheme (ML-DSA) that the host can **verify** (it never holds a private key). This underpins the [auth login stack](../auth/how-it-works.md); you reach it through `AuthService`, not through `crypto` directly, so it is documented there rather than here.\n\n## Related\n\n- [Auth: how it works](../auth/how-it-works.md), the post-quantum login stack built on host-side verify.\n- [Cookies](./cookies.md), signed and encrypted cookies built on `crypto`.\n- [Email](./email.md), whose `TwoFactor` codes are HMAC tokens.\n- [Environment and secrets](./environment.md), where you keep signing and encryption keys.\n",