toiljs 0.0.59 → 0.0.61

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.
Files changed (158) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +311 -118
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/index.d.ts +1 -1
  7. package/build/client/index.js +1 -1
  8. package/build/client/routing/mount.js +12 -1
  9. package/build/client/ssr/markers.d.ts +1 -0
  10. package/build/client/ssr/markers.js +3 -0
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/config.d.ts +21 -0
  13. package/build/compiler/config.js +35 -0
  14. package/build/compiler/docs.d.ts +2 -1
  15. package/build/compiler/docs.js +33 -304
  16. package/build/compiler/index.d.ts +13 -0
  17. package/build/compiler/index.js +113 -21
  18. package/build/compiler/template-build.d.ts +21 -1
  19. package/build/compiler/template-build.js +110 -26
  20. package/build/compiler/toil-docs.generated.d.ts +1 -0
  21. package/build/compiler/toil-docs.generated.js +20 -0
  22. package/build/devserver/.tsbuildinfo +1 -1
  23. package/build/devserver/daemon/catalog.d.ts +26 -0
  24. package/build/devserver/daemon/catalog.js +48 -0
  25. package/build/devserver/daemon/cron.d.ts +4 -0
  26. package/build/devserver/daemon/cron.js +50 -0
  27. package/build/devserver/daemon/host.d.ts +37 -0
  28. package/build/devserver/daemon/host.js +94 -0
  29. package/build/devserver/daemon/index.d.ts +34 -0
  30. package/build/devserver/daemon/index.js +241 -0
  31. package/build/devserver/db/catalog.d.ts +2 -0
  32. package/build/devserver/db/catalog.js +80 -0
  33. package/build/devserver/db/database.d.ts +80 -0
  34. package/build/devserver/db/database.js +1032 -0
  35. package/build/devserver/db/index.d.ts +3 -0
  36. package/build/devserver/db/index.js +3 -0
  37. package/build/devserver/db/routeKinds.d.ts +8 -0
  38. package/build/devserver/db/routeKinds.js +139 -0
  39. package/build/devserver/db/types.d.ts +121 -0
  40. package/build/devserver/db/types.js +52 -0
  41. package/build/devserver/email/index.js +1 -1
  42. package/build/devserver/index.d.ts +19 -24
  43. package/build/devserver/index.js +11 -165
  44. package/build/devserver/mstore/store.d.ts +18 -0
  45. package/build/devserver/mstore/store.js +82 -0
  46. package/build/devserver/{host.d.ts → runtime/host.d.ts} +7 -1
  47. package/build/devserver/{host.js → runtime/host.js} +51 -7
  48. package/build/devserver/{module.d.ts → runtime/module.d.ts} +2 -1
  49. package/build/devserver/{module.js → runtime/module.js} +34 -1
  50. package/build/devserver/server.d.ts +23 -0
  51. package/build/devserver/server.js +223 -0
  52. package/build/devserver/ssr.d.ts +25 -0
  53. package/build/devserver/ssr.js +114 -0
  54. package/build/devserver/wasm/sections.d.ts +2 -0
  55. package/build/devserver/wasm/sections.js +42 -0
  56. package/build/devserver/wasm/surface.d.ts +18 -0
  57. package/build/devserver/wasm/surface.js +41 -0
  58. package/docs/README.md +4 -4
  59. package/docs/auth-todo.md +6 -6
  60. package/docs/caching.md +5 -5
  61. package/docs/cli.md +15 -0
  62. package/docs/client.md +40 -0
  63. package/docs/crypto.md +4 -4
  64. package/docs/data.md +6 -6
  65. package/docs/email.md +28 -28
  66. package/docs/environment.md +10 -10
  67. package/docs/index.md +26 -0
  68. package/docs/ratelimit.md +10 -10
  69. package/docs/routing.md +2 -2
  70. package/docs/server.md +61 -0
  71. package/docs/ssr.md +561 -113
  72. package/docs/styling.md +22 -0
  73. package/docs/time.md +3 -3
  74. package/eslint.config.js +10 -1
  75. package/examples/basic/client/components/Header.tsx +3 -0
  76. package/examples/basic/client/routes/features/actions.tsx +0 -2
  77. package/examples/basic/client/routes/hello.tsx +89 -19
  78. package/examples/basic/client/styles/main.css +48 -0
  79. package/examples/basic/server/SsrHelloRender.ts +97 -0
  80. package/examples/basic/server/main.ts +5 -0
  81. package/examples/basic/server/migrations/GuestEntry.migration.ts +39 -0
  82. package/examples/basic/server/streams/Echo.ts +49 -0
  83. package/package.json +12 -10
  84. package/scripts/gen-toil-docs.mjs +96 -0
  85. package/server/runtime/time.ts +3 -3
  86. package/src/cli/create.ts +40 -3
  87. package/src/cli/db.ts +158 -0
  88. package/src/cli/diagnostics.ts +19 -0
  89. package/src/cli/doctor.ts +20 -0
  90. package/src/cli/index.ts +10 -0
  91. package/src/cli/update.ts +58 -0
  92. package/src/client/index.ts +1 -1
  93. package/src/client/routing/mount.tsx +18 -2
  94. package/src/client/ssr/markers.tsx +22 -0
  95. package/src/compiler/config.ts +88 -2
  96. package/src/compiler/docs.ts +47 -308
  97. package/src/compiler/index.ts +236 -32
  98. package/src/compiler/ssr-codegen.ts +1 -1
  99. package/src/compiler/template-build.ts +247 -46
  100. package/src/compiler/toil-docs.generated.ts +26 -0
  101. package/src/devserver/daemon/catalog.ts +120 -0
  102. package/src/devserver/daemon/cron.ts +87 -0
  103. package/src/devserver/daemon/host.ts +224 -0
  104. package/src/devserver/daemon/index.ts +349 -0
  105. package/src/devserver/db/catalog.ts +108 -0
  106. package/src/devserver/db/database.ts +1633 -0
  107. package/src/devserver/db/index.ts +18 -0
  108. package/src/devserver/db/routeKinds.ts +147 -0
  109. package/src/devserver/db/types.ts +139 -0
  110. package/src/devserver/email/index.ts +1 -1
  111. package/src/devserver/index.ts +31 -287
  112. package/src/devserver/mstore/store.ts +121 -0
  113. package/src/devserver/{host.ts → runtime/host.ts} +98 -7
  114. package/src/devserver/{module.ts → runtime/module.ts} +47 -1
  115. package/src/devserver/server.ts +393 -0
  116. package/src/devserver/ssr.ts +166 -0
  117. package/src/devserver/wasm/sections.ts +59 -0
  118. package/src/devserver/wasm/surface.ts +88 -0
  119. package/test/daemon-build.test.ts +198 -0
  120. package/test/daemon-catalog.test.ts +265 -0
  121. package/test/daemon-emulation.test.ts +216 -0
  122. package/test/db.test.ts +0 -0
  123. package/test/devserver-database.test.ts +510 -14
  124. package/test/devserver-pqauth.test.ts +1 -1
  125. package/test/devserver-secrets.test.ts +5 -1
  126. package/test/doctor.test.ts +13 -0
  127. package/test/email-preview.test.ts +6 -1
  128. package/test/example-guestbook.test.ts +43 -1
  129. package/test/fixtures/daemon-app.ts +56 -0
  130. package/test/global-setup.ts +17 -0
  131. package/test/pqauth-e2e.test.ts +1 -1
  132. package/test/ssr-render.test.ts +94 -27
  133. package/test/ssr-template.test.tsx +44 -1
  134. package/vitest.config.ts +3 -0
  135. package/build/devserver/database.d.ts +0 -8
  136. package/build/devserver/database.js +0 -418
  137. package/src/devserver/database.ts +0 -618
  138. /package/build/devserver/{dotenv.d.ts → config/dotenv.d.ts} +0 -0
  139. /package/build/devserver/{dotenv.js → config/dotenv.js} +0 -0
  140. /package/build/devserver/{env.d.ts → config/env.d.ts} +0 -0
  141. /package/build/devserver/{env.js → config/env.js} +0 -0
  142. /package/build/devserver/{ratelimit.d.ts → config/ratelimit.d.ts} +0 -0
  143. /package/build/devserver/{ratelimit.js → config/ratelimit.js} +0 -0
  144. /package/build/devserver/{cache.d.ts → http/cache.d.ts} +0 -0
  145. /package/build/devserver/{cache.js → http/cache.js} +0 -0
  146. /package/build/devserver/{envelope.d.ts → http/envelope.d.ts} +0 -0
  147. /package/build/devserver/{envelope.js → http/envelope.js} +0 -0
  148. /package/build/devserver/{proxy.d.ts → http/proxy.d.ts} +0 -0
  149. /package/build/devserver/{proxy.js → http/proxy.js} +0 -0
  150. /package/build/devserver/{crypto.d.ts → runtime/crypto.d.ts} +0 -0
  151. /package/build/devserver/{crypto.js → runtime/crypto.js} +0 -0
  152. /package/src/devserver/{dotenv.ts → config/dotenv.ts} +0 -0
  153. /package/src/devserver/{env.ts → config/env.ts} +0 -0
  154. /package/src/devserver/{ratelimit.ts → config/ratelimit.ts} +0 -0
  155. /package/src/devserver/{cache.ts → http/cache.ts} +0 -0
  156. /package/src/devserver/{envelope.ts → http/envelope.ts} +0 -0
  157. /package/src/devserver/{proxy.ts → http/proxy.ts} +0 -0
  158. /package/src/devserver/{crypto.ts → runtime/crypto.ts} +0 -0
@@ -0,0 +1,26 @@
1
+ /* eslint-disable */
2
+ // AUTO-GENERATED by scripts/gen-toil-docs.mjs from docs/*.md, do not edit.
3
+ // `docs/*.md` is the single source for both the human docs and these agent docs.
4
+ // Regenerate with `node scripts/gen-toil-docs.mjs` (runs before `build:compiler`).
5
+
6
+ /** The framework guides written into `.toil/docs/`, keyed by filename, generated from `docs/`. */
7
+ export const TOIL_DOCS: Record<string, string> = {
8
+ "index.md": "# toiljs\n\nA full-stack React framework: a Vite-bundled client SPA with file-based routing, plus a\ntoilscript-to-WebAssembly server target.\n\n## Project layout\n\n- `client/`, the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,\n `public/`, and `toil.tsx` (the entry that calls `Toil.mount`).\n- `server/`, the toilscript → WASM target (`@main` entry), compiled by `toilscript`.\n `@data`/`@remote`/`@service` here generate the typed client `Server` API (see [server.md](./server.md)).\n- `toil.config.ts`, configuration via `defineConfig` (`toiljs.config.ts` also works).\n- Generated, gitignored, do not edit: `.toil/` (working dir), `toil-env.d.ts` (ambient\n globals), `toil-routes.d.ts` (typed routes), `shared/server.ts` (the typed RPC module,\n emitted by the server build; import `@data` classes from `shared/server`).\n\n## Key ideas\n\n- `Toil` is a native global (no import): `Toil.Link`, `Toil.useRouter`, `Toil.useLoaderData`,\n etc. The IO classes (`FastMap`, `FastSet`, `DataWriter`, `DataReader`), `parseError`, and the\n generated `Server` RPC surface are globals too.\n- Scripts: `npm run dev` (HMR), `npm run build` (→ `build/client` + `build/server`),\n `npm start` (self-host the build).\n\nSee [routing.md](./routing.md), [client.md](./client.md), [styling.md](./styling.md),\n[server.md](./server.md), [ssr.md](./ssr.md), [cli.md](./cli.md).\n",
9
+ "getting-started.md": "# Getting started\n\nA toiljs app has two halves that build and ship together:\n\n- a **server** written in ToilScript, compiled to a single WebAssembly module\n (`build/server/release.wasm`), and\n- a **client** (Vite + React) that talks to the server through a generated,\n fully typed `Server` proxy.\n\nThe server runs one fresh wasm instance per request, identically on the dev\nserver and on the edge. There is no Node.js in the request path: your handler is\nwasm.\n\n## Project layout\n\n```\nproject/\n toilconfig.json server (wasm) build config: entries, target, AS options\n toil.config.ts client config (defineConfig: dev/build/SEO options)\n\n server/\n main.ts wires Server.handler, re-exports the wasm exports + abort\n routes/*.ts @rest controllers (auto-discovered)\n services/*.ts @service / @remote (auto-discovered)\n core/AppHandler.ts your top-level ToilHandler\n models/*.ts @data / @user classes\n\n shared/\n server.ts GENERATED by the server build (--rpcModule): the typed\n client surface (Server proxy, @data codecs, getUser)\n\n client/\n routes/*.tsx file-based pages\n layout.tsx, 404.tsx root layout / not-found\n styles/*.css\n\n build/\n server/release.wasm compiled server (+ release.wat text form)\n client/ Vite output\n```\n\nThe compiler discovers every `.ts` under `server/` that declares a decorated\nsurface (`@rest`, `@service`, `@remote`, `@data`, `@user`) on its own. Importing\nthose modules from `main.ts` is still good practice: it keeps a direct\n`toilscript` run (which only sees the `toilconfig.json` entries) building the\nexact same server.\n\n## `main.ts`\n\nThree things are required, and the comments in the scaffold say so:\n\n```ts\nimport { Server } from 'toiljs/server/runtime';\nimport { revertOnError } from 'toiljs/server/runtime/abort/abort';\n\nimport { AppHandler } from './core/AppHandler';\n\n// Pull every decorated surface into a direct `toilscript` build.\nimport './routes/Players';\nimport './services/Stats';\n\n// 1. The handler factory: one fresh handler instance per request.\nServer.handler = () => new AppHandler();\n\n// 2. Re-export the wasm entrypoints (`handle`, `render`).\nexport * from 'toiljs/server/runtime/exports';\n\n// 3. The AssemblyScript trap hook.\nexport function abort(message: string, fileName: string, line: u32, column: u32): void {\n revertOnError(message, fileName, line, column);\n}\n```\n\nIf all you need is `@rest` routing, your handler can be `RestHandler` (see\n[Routing](./routing.md)) and you do not have to write an `AppHandler` at all.\n\n## The request lifecycle\n\nFor each request the runtime (`server/runtime/exports`):\n\n1. decodes the request envelope into a [`Request`](./routing.md#request),\n2. publishes it ambiently as `Server.currentRequest` (so `AuthService.getUser()`\n and friends can read its cookies with no argument),\n3. builds the handler via `Server.handler()` and calls\n `onRequestStarted` → `handle(req)` → `onRequestCompleted`,\n4. encodes the returned [`Response`](./routing.md#response) and clears the\n ambient request.\n\nBecause the instance is fresh and memory is wiped between requests, **nothing in\nmodule globals survives across requests.** Anything that must persist (accounts,\nsessions you do not put in a cookie, rate-limit counters) belongs in an external\nstore reached through a host binding.\n\n## CLI\n\nThe `toiljs` CLI drives both halves:\n\n| Command | What it does |\n| --- | --- |\n| `toiljs create [name]` | Scaffold a new app (templates, styling, options). |\n| `toiljs dev` | Dev server with hot reload: watches `server/`, rebuilds the wasm via toilscript, regenerates `shared/server.ts`, and runs Vite for the client. Flags: `--root <dir>`, `--port <n>`, `--host`. |\n| `toiljs build` | Production build: server wasm first (so `shared/server.ts` is fresh), then the Vite client + static prerender. Flags: `--root <dir>`, `--server` (server only). |\n| `toiljs start` | Self-host a built app. Flags: `--root`, `--port`, `--host`. |\n| `toiljs doctor` | Diagnose setup/deps (`--json`, `--fix`). |\n\nIn dev, requests whose method matches a dispatchable verb go into the wasm\nfirst; if the guest reports \"no route matched\" (the `x-toil-unhandled` marker)\nthe request falls through to Vite, so client routes and assets just work\nalongside your API.\n\n## Building the server by hand\n\n`toiljs build` runs toilscript for you, but you can invoke it directly (this is\nwhat the examples do):\n\n```sh\ntoilscript --target release --rpcModule shared/server.ts\n```\n\n`--target release` reads `toilconfig.json` and emits the wasm at\n`targets.release.outFile`; `--rpcModule shared/server.ts` writes the generated\ntyped client (see [RPC](./rpc.md)).\n\n## Next\n\n- [Routing](./routing.md) to expose HTTP endpoints.\n- [Data codec](./data.md) for request/response bodies.\n- [Auth](./auth.md) for login and sessions.\n",
10
+ "routing.md": "# Routing\n\ntoiljs routing is decorator-driven. You write a controller class, annotate it\nwith `@rest` and its methods with verb decorators, and the ToilScript compiler\ngenerates the dispatcher. Routes can take a typed body, read path params and the\nraw request through a `RouteContext`, and return either a `Response` or a typed\nvalue that is auto-encoded.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('players')\nclass Players {\n @get('/:id')\n public get(ctx: RouteContext): Response {\n const id = ctx.param('id');\n return Response.json(`{\"id\":\"${id}\"}`);\n }\n\n @post('/')\n public create(input: NewPlayer): Player {\n // `input` is the decoded request body; returning a @data value JSON-encodes it\n return Player.from(input);\n }\n}\n```\n\n## `@rest` controllers\n\n`@rest` marks a class as a route controller and mounts it at a prefix.\n\n```ts\n@rest('api') // mounted at /api\n@rest('/') // or @rest('') // mounted at the root\n@rest({ stream: DataStream.Binary }) // root mount, binary codec by default\n```\n\n- The string argument is the mount prefix. `\"api\"`, `\"/api\"`, and `\"api/\"` all\n normalize to `/api`; `\"\"` and `\"/\"` mean the root.\n- The object form sets class-wide defaults. `stream: DataStream.Binary` makes\n every route in the class use the binary `@data` codec; the default is\n `DataStream.JSON`. Individual routes override this with `@route`.\n\nThe compiler injects, at module init, a registration that adds the controller to\nthe global `Rest` registry. Controllers dispatch in the order their modules are\nloaded; routes within a controller try in declaration order, first match wins.\n\n## Verb decorators\n\nEach HTTP method has a decorator taking a single path string:\n\n```ts\n@get('/path') @post('/path') @put('/path') @delete('/path')\n@patch('/path') @head('/path') @options('/path')\n```\n\nThe full path is the controller prefix joined with the route path\n(`prefix=\"/api\"`, `@get(\"/todos/:id\")` → `/api/todos/:id`).\n\n### `@route` (explicit form)\n\n`@route` is the general form; use it when you need to set the stream mode per\nroute or prefer an object:\n\n```ts\n@route({ method: Methods.POST, path: '/upload', stream: DataStream.Binary })\npublic upload(body: FileData): FileResult { /* ... */ }\n```\n\n`method` (from the `Methods` enum) and `path` are required; `stream` is\noptional and overrides the controller default.\n\n## Path parameters\n\nA `:name` segment captures that URL segment. Read it with `ctx.param(\"name\")`:\n\n```ts\n@get('/todos/:id/items/:itemId')\npublic getItem(ctx: RouteContext): Response {\n const id = ctx.param('id');\n const itemId = ctx.param('itemId');\n return Response.json(`{\"todo\":\"${id}\",\"item\":\"${itemId}\"}`);\n}\n```\n\nMatching is segment-exact: the request path must have the same number of\nsegments, static segments must match literally, and `:param` segments capture\nthe value. The query string is stripped before matching.\n\n## Method parameters\n\nA route method takes zero, one, or two parameters, classified by type:\n\n- a `RouteContext` parameter receives the match context (path params, query,\n headers, raw body);\n- any other type is treated as the **request body**, decoded as a `@data` value.\n\n```ts\n@get('/status')\npublic status(): StatusResponse { /* no body, no context */ }\n\n@get('/user/:id')\npublic getUser(ctx: RouteContext): User { /* context only */ }\n\n@post('/create')\npublic create(input: NewTodo): Todo { /* body only */ }\n\n@post('/user/:id/score')\npublic addScore(input: ScoreDelta, ctx: RouteContext): Player {\n const id = ctx.param('id'); /* body AND context */\n}\n```\n\nThe body is decoded per the route's stream mode: in JSON mode from\n`JSON.parse(ctx.text())`, in Binary mode from `Body.decode(req.body)`. See\n[Data codec](./data.md).\n\n## Return types\n\nThe compiler encodes the return value by its type:\n\n| Return type | Result |\n| --- | --- |\n| `Response` | Returned as-is. Full control over status, headers, body. |\n| `void` | `204 No Content`. |\n| a `@data` type, JSON stream | `Response.json(value.toJSON().toString())`. |\n| a `@data` type, Binary stream | `Response.bytes(value.encode())`. |\n\nReturning a `Response` lets you set status, headers, cookies, and caching\ndirectly; returning a typed value is the terse path when you just want the data\nserialized.\n\n## Data streams\n\nEach route is either **JSON** (default) or **Binary**:\n\n- **JSON**, the body is `JSON.parse`d and revived via the `@data` type's\n `fromJSON`; the response is the type's `toJSON()`. 64-bit-and-larger integers\n cross the wire as decimal strings (exact at any size). Best for endpoints a\n browser or third party calls directly.\n- **Binary**, the body is `Body.decode(bytes)` and the response is\n `value.encode()`, using the deterministic `DataWriter`/`DataReader` codec. No\n precision loss, smaller, faster. Best for app-to-app and anything\n security-sensitive.\n\nSet the mode on the controller (`@rest({ stream: DataStream.Binary })`) or per\nroute (`@route({ ..., stream: DataStream.Binary })`).\n\n## Dispatch and the 404 fallback\n\nAt runtime the global `Rest` registry tries each controller in order:\n\n```ts\nconst hit = Rest.dispatch(req); // Response | null\nif (hit != null) return hit; // first matching route's Response\nreturn Response.unhandled(); // no route matched\n```\n\n`RestHandler` is a ready-made handler that does exactly this, so a REST-only app\nneeds no custom handler:\n\n```ts\nimport { RestHandler } from 'toiljs/server/runtime';\nServer.handler = () => new RestHandler();\n```\n\n`Response.unhandled()` is a `404` carrying the `x-toil-unhandled` marker header.\nOn the dev server and edge that marker means \"no route matched here\" and lets\nthe request fall through to the next layer (Vite in dev, static/SSR on the\nedge). A deliberate `Response.notFound()` does **not** carry the marker and is\nsent to the client verbatim.\n\n---\n\n## `Request`\n\nThe decoded incoming request (`server/runtime/request.ts`).\n\n### Fields\n\n| Field | Type | Notes |\n| --- | --- | --- |\n| `method` | `Method` | `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `UNKNOWN`. |\n| `path` | `string` | Path including the query string. |\n| `headers` | `Array<Header>` | Ordered; a `Header` is `{ name, value }`. |\n| `body` | `Uint8Array` | Raw request body bytes. |\n\n### Methods\n\n| Method | Signature | Notes |\n| --- | --- | --- |\n| `header` | `header(name: string): string \\| null` | Case-insensitive lookup, `null` if absent. |\n| `cookies` | `cookies(): CookieMap` | Parses the `Cookie` header (percent-decoded values); cached for the request. |\n| `cookie` | `cookie(name: string): string \\| null` | A single cookie value, or `null`. |\n\nThe `Method` enum and `Header` class are exported from\n`toiljs/server/runtime`.\n\n## `RouteContext`\n\nPassed to any route method that declares a `RouteContext` parameter\n(`server/runtime/rest/RouteContext.ts`).\n\n| Member | Signature | Notes |\n| --- | --- | --- |\n| `request` | `Request` | The raw incoming request. |\n| `param` | `param(name: string): string` | Captured path param; `\"\"` if absent. |\n| `query` | `query(name: string): string` | Query-string value; `\"\"` if absent. Not URL-decoded in v1. |\n| `header` | `header(name: string): string \\| null` | Case-insensitive request header. |\n| `text` | `text(): string` | The request body decoded as UTF-8. |\n\n## `Response`\n\nThe outgoing response builder (`server/runtime/response.ts`). Construct one with\na static factory, then chain instance methods (each returns the same `Response`).\n\n### Constructor\n\n```ts\nnew Response(status: u16, body: Uint8Array, headers: Array<Header> | null = null)\n```\n\n### Static factories\n\n| Factory | Signature | Status | Content-Type |\n| --- | --- | --- | --- |\n| `Response.text` | `text(body: string, status: u16 = 200)` | 200 | `text/plain; charset=utf-8` |\n| `Response.html` | `html(body: string, status: u16 = 200)` | 200 | `text/html; charset=utf-8` |\n| `Response.json` | `json(body: string, status: u16 = 200)` | 200 | `application/json; charset=utf-8` |\n| `Response.bytes` | `bytes(body: Uint8Array, status: u16 = 200)` | 200 | `application/octet-stream` |\n| `Response.empty` | `empty(status: u16)` | custom | (none) |\n| `Response.notFound` | `notFound()` | 404 | text |\n| `Response.badRequest` | `badRequest(msg = 'bad request')` | 400 | text |\n| `Response.internalError` | `internalError(msg = 'internal error')` | 500 | text |\n| `Response.unhandled` | `unhandled()` | 404 | text + `x-toil-unhandled` marker |\n\n`json` takes an already-serialized string; build it with `DataWriter`-free JSON\nor a `@data` type's `toJSON().toString()`. For binary, prefer `bytes`.\n\n### Instance methods\n\n| Method | Signature | Notes |\n| --- | --- | --- |\n| `setHeader` | `setHeader(name: string, value: string): Response` | Appends a header (repeatable). |\n| `setCookie` | `setCookie(cookie: Cookie): Response` | Appends a `Set-Cookie`. Call again for more. |\n| `setCookieKV` | `setCookieKV(name: string, value: string): Response` | Shorthand for `setCookie(new Cookie(name, value))`. |\n| `clearCookie` | `clearCookie(name: string, path = '/', domain = ''): Response` | Emits a deletion `Set-Cookie` (empty value, `Max-Age=0`). |\n| `cache` | `cache(edgeTtlMinutes: u16, browserTtlSeconds: u32 = 0, privateScope: bool = false, allowAuth: bool = false): Response` | Marks the response cacheable. See [Caching](./caching.md). |\n| `cacheFor` | `cacheFor(minutes: u16): Response` | Shorthand for `cache(minutes)` (edge only). |\n\n```ts\nreturn Response.json('{\"id\":42}')\n .setHeader('x-trace', traceId)\n .setCookie(Cookie.create('sid', token).httpOnly().secure())\n .cacheFor(5);\n```\n\nSee [Cookies](./cookies.md) for the cookie builder, and [Caching](./caching.md)\nfor the cache directives.\n",
11
+ "client.md": "# Client runtime\n\nEverything is on the `Toil` global, no imports needed in route files.\n\n## Entry\n\n`client/toil.tsx` imports the route table + global styles and mounts the app:\n\n```tsx\nimport { routes, layout, notFound } from \"toiljs/routes\";\nimport \"./styles/main.css\";\nToil.mount(routes, layout, notFound);\n```\n\n## API (on `Toil`)\n\n- Components: `Link`, `NavLink`, `Head`\n- Navigation: `navigate`, `useRouter`, `useNavigate`\n- Location: `usePathname`, `useSearchParams`, `useParams`, `useNavigationPending`\n- Data: `useLoaderData` (see [routing.md](./routing.md))\n- Head: `useHead`, `useTitle`, `<Head>`, set the `<title>` / meta per route\n- Realtime: `useChannel`, `connectChannel` (WebSocket to the backend at `/_toil`)\n- IO globals (no `Toil.` prefix): `FastMap`, `FastSet`, `DataWriter`, `DataReader`\n- `parseError(err)` global: message from an unknown caught value (handy in `catch`)\n- `Server` global: the typed RPC surface generated from the server (see [server.md](./server.md))\n- `Server.REST.<controller>.<route>(args)`: a working, typed `fetch` client for your\n `@rest` controllers, e.g. `await Server.REST.todos.getTodo({ params: { id } })` or\n `await Server.REST.todos.add({ body: new AddTodo(\"milk\") })`. `args` is\n `{ params?, body?, query?, headers? }`; returns are typed (`@data` classes are parsed for\n you). The REST client attaches when you import from `shared/server`.\n\n## Head example\n\n```tsx\nToil.useHead({\n title: \"Blog\",\n titleTemplate: \"%s, MyApp\",\n meta: [{ name: \"description\", content: \"...\" }],\n});\n```\n",
12
+ "styling.md": "# Styling\n\nThe app imports one stylesheet from `client/toil.tsx` (e.g. `./styles/main.css`).\n\n## Preprocessors & Tailwind\n\nPick a CSS preprocessor (none / Sass / Less / Stylus) and optionally Tailwind at\n`toiljs create`, or change it later on an existing project:\n\n```sh\ntoiljs configure # interactive\ntoiljs configure --tailwind # add Tailwind\ntoiljs configure --style sass # switch preprocessor\n```\n\n`configure` installs/removes the right packages and rewrites the imports. Tailwind lives\nin its own `styles/tailwind.css` (`@import \"tailwindcss\";`).\n\n## Imports\n\n`.css` / `.scss` / `.sass` / `.less` / `.styl` and image imports (`.svg`, `.png`, …) are\ntyped via `toil-env.d.ts`.\n",
13
+ "server.md": "# Server (toilscript → WebAssembly)\n\n`server/` is the toilscript source, compiled to WebAssembly by `toilscript`.\n\n- `server/main.ts`, the `@main` entry, exported as the WASM `main`.\n- `server/index.ts`, your functions.\n- `server/tsconfig.json`, extends `toilscript/std/assembly.json` (AssemblyScript/toilscript\n globals like `i32`, not the DOM), so editors resolve server types correctly.\n- `npm run build:server` (or `npm run build`) emits `build/server/release.wasm` and\n regenerates `shared/server.ts` (the typed client RPC module).\n\n## Typed RPC (`@data` / `@remote` / `@service`)\n\nTag server code and the build generates a typed client `Server` surface:\n\n- `@data class X {}`, a serializable struct. Generates a client class with the same fields\n plus `encode`/`decode`; construct it on the client: `import { X } from \"shared/server\"`.\n- `@remote function f(a: T): R`, a client-callable endpoint, becomes `Server.f(a)`.\n- `@service class S { @remote m(...) {} }`, namespaces methods: `Server.s.m(...)`.\n\nOn the client, `Server` is a global (no import) and fully typed; every call is async\n(`Promise<R>`). Inputs/outputs are scalars, arrays, or `@data` classes, both directions.\n\nNote: the client↔server transport is not wired yet, so calling a `Server` method throws\nuntil it lands; the typed surface + codec are generated and ready.\n\n## HTTP REST (`@rest` / `@route`)\n\nTag a class `@rest` and its methods with a verb to expose a real HTTP API. Unlike RPC,\nthe generated client is working `fetch` code (it is just HTTP).\n\n- `@rest(\"api\") class Todos {}`, mounts the controller at `/api` (bare `@rest` → `/`).\n- `@get(\"/todos/:id\")` / `@post` / `@del` / `@put` / `@patch` / `@head` / `@options`, verb\n shortcuts; or `@route({ method: Methods.GET, path: \"/todos\", stream: DataStream.JSON })`.\n- A method takes an optional `@data` body + an optional `ctx: RouteContext` (path params via\n `ctx.param(\"id\")`, `ctx.query(...)`, `ctx.header(...)`). It returns either a `@data` type,\n which the compiler encodes per `stream` (`DataStream.JSON` default, or `DataStream.Binary`,\n lossless for large `u64`/bignum), or a `Response` for full control - custom status and\n headers, e.g. `Response.json(value.toJSON().toString()).setHeader(\"cache-control\", \"no-store\")`\n or `Response.notFound()`. (The editor sees the compiler-injected `@data` `toJSON`/`encode`\n members via the toilscript plugin, so serializing into a `Response` is editor-clean.)\n\nEach `@rest` class self-registers; dispatch them from your handler - it composes, it never\ntakes over `handle()`:\n\n```ts\nimport { ToilHandler, Request, Response, Rest } from \"toiljs/server/runtime\";\nexport class App extends ToilHandler {\n public handle(req: Request): Response {\n const hit = Rest.dispatch(req); // try every @rest controller\n if (hit != null) return hit;\n return Response.notFound(); // your own logic / static fallback\n }\n}\n```\n\nFor a REST-only project, `Server.handler = () => new RestHandler()` does the same with no\nboilerplate. On the client: `Server.REST.todos.getTodo({ params: { id } })` (see [client.md](./client.md)).\n\nFor the full reference (`@rest`/verb decorators, `RouteContext`, `Request`, `Response`,\ndispatch + the 404 fallback) see [routing.md](./routing.md).\n",
14
+ "ssr.md": "# Server-side rendering (SSR)\n\ntoiljs server-renders a route by splitting it into two halves that the build\nkeeps coherent:\n\n- **the template**, the HTML shell with the dynamic bits punched out (named\n *holes*). It is React's own `renderToStaticMarkup` output with the holes\n removed, precompiled at build time and held (mmap'd) by the edge.\n- **the values**, the hole values for one request (text, attributes, repeated\n rows, headers, status). The wasm guest's `render` entrypoint returns *only*\n these. The edge splices them into the template.\n\nA 32-byte template **hash** travels with the values so the edge can reject a\nguest that was compiled against a different template than the one deployed.\n\nThis split is the whole point. The guest never re-runs React and never emits the\nstatic page bytes, it stamps a tiny `(slot_id, kind, value)` list, so the wasm\nstays small and a request is served about as fast as a static file, while still\ndelivering real first-paint HTML and SEO. The browser then hydrates the spliced\nmarkup in place with no flash and no client re-render, because the holes are\nescaped exactly the way React escapes them, so the bytes match.\n\nThis page is about server-rendered HTML. JSON / binary API endpoints use\n[Routing](./routing.md) and `@rest` (see [Server](./server.md)) instead.\n\nThe running example throughout is the basic example's `/hello` route:\n\n- `examples/basic/client/routes/hello.tsx`, the route (`ssr = true`, holes, loader)\n- `examples/basic/server/SsrHelloRender.ts`, the server `render` + `Ssr.register`\n- `examples/basic/server/_ssr/hello.slots.ts`, the generated `Slot` + `HASH` (gitignored, never hand-edited)\n\n---\n\n## 1. Authoring an SSR route\n\nOpt a route in with `export const ssr = true`. At build time toiljs renders the\npage ONCE (under its real layout chain, with sample loader data) into the\ntemplate, generates the route's typed `Slot` module, and writes the template\nmanifest the edge serves. Routes without `ssr = true` are untouched and render\npurely on the client as before.\n\nMark the dynamic bits with the four hole markers from `toiljs/client`. They are\n**transparent in the browser**, `<Hole>` renders its children, `<Repeat>`\nrenders `each.map(...)`, `<RawHtml>` renders a `dangerouslySetInnerHTML`\nwrapper, `<Island>` renders its children, so the same component is your normal\nclient UI. Only the build extractor and the server `render` treat them\nspecially.\n\n```tsx\nimport { Hole, Island, RawHtml, Repeat, useLoaderData } from 'toiljs/client';\n\nexport const ssr = true;\n\nexport const loader = ({ params }: { params: Record<string, string> }) => ({\n name: params.name ?? 'world',\n blurbHtml: 'Rendered at the <strong>edge</strong>.',\n services: [{ name: 'record', region: 'us-east' }],\n});\n\nexport default function Hello() {\n const d = useLoaderData<typeof loader>();\n return (\n <section className=\"hello\">\n <h1>Hello, <Hole id=\"name\">{d.name}</Hole>!</h1>\n\n <p><RawHtml id=\"blurb\" html={d.blurbHtml} as=\"span\" /></p>\n\n <ul>\n <Repeat id=\"services\" each={d.services}>\n {(s) => (\n <li>\n <strong><Hole id=\"svcName\">{s.name}</Hole></strong>\n <span className=\"hello-region\"><Hole id=\"svcRegion\">{s.region}</Hole></span>\n </li>\n )}\n </Repeat>\n </ul>\n\n <Island>\n <p>Hydrated at {new Date().toLocaleTimeString()}.</p>\n </Island>\n </section>\n );\n}\n```\n\n### The loader at build time\n\nThe build calls your `loader` once with synthesized sample params to obtain\nrepresentative data, then renders the page with it. Only the **shape** of the\ndata matters at build time, it drives which holes exist and (for `<Repeat>`)\ncaptures the row sub-template. The real per-request values come from the\n**server** `render`, not from this loader. Note in particular that `<Repeat>`\nneeds the sample to have **at least one row** so the build can capture the row\nmarkup; an empty array at build time leaves nothing to stamp.\n\n### The four hole markers\n\n| Marker | Prop(s) | Server (build + render) | Browser |\n| --- | --- | --- | --- |\n| `<Hole id>` | `id` | a text insertion point; the guest fills it with the **escaped** value | renders `children` |\n| `<RawHtml id html as?>` | `id`, `html`, `as?` (wrapper tag, default `div`) | emits `<as>…</as>`; the guest fills the inner HTML **verbatim** (you sanitize) | `<as dangerouslySetInnerHTML>` |\n| `<Repeat id each>` | `id`, `each`, child `(item, index) => node` | captures the **one** row sub-template; the guest stamps it per item and concatenates | renders `each.map(children)` |\n| `<Island>` | `children` | renders **nothing** (empty in the server HTML) | renders `children` |\n\n`<RawHtml>` always needs a host element so the server and client DOM agree; that\nis the `as` wrapper (defaults to `div`). The captured `<as>` tag is part of the\ntemplate, only its inner HTML is a hole.\n\n### Attribute holes (`attr()`)\n\nAn attribute value is not a child node, so it cannot be a JSX marker element.\nUse the `attr(id, value)` helper from `toiljs/client` in attribute position\ninstead:\n\n```tsx\nimport { attr } from 'toiljs/client';\n\n<a href={attr('profileUrl', d.url)} class={attr('cls', d.cls)}>…</a>\n```\n\nBrowser: `attr` returns `value` unchanged. Build: it emits an `attr` hole at the\nattribute's byte offset (stripping to an empty value in the `.tmpl`). The server\n`render` fills it with `v.setAttr(Slot.profileUrl, url)` (React-escaped, the same\nas `setText`), and the host splices it between the quotes. It composes with\nliteral text in the same attribute (`` class={`btn ${attr('x', v)}`} ``).\n\n### SSR-safe routes (and `<Island>`)\n\nFor hydration to be byte-clean, the route **and the layouts above it** must\nrender under static markup: use the hole markers and `useLoaderData`, and put\nanything that needs router hooks (`useRouter`, `usePathname`, …) or browser-only\nAPIs (`window`, `Date.now`, …) inside an `<Island>`. An island is empty in the\nfirst paint and appears only after hydration, so it gets no first-paint HTML or\nSEO, which is exactly right for \"client only\" content.\n\nOpting in is always safe: a route (or a layout in its chain) that **throws**\nunder static markup is **skipped at build with a warning** and falls back to\nnormal client rendering. You never get a broken page from adding `ssr = true`;\nworst case you get client rendering and a build warning telling you why.\n\n---\n\n## 2. The server `render`\n\nThe wasm `render(req_ofs, req_len) -> i64` export is surfaced by your\n`server/main.ts` via `export * from 'toiljs/server/runtime/exports'` (the same\nline that surfaces `handle`). At request time it decodes the request, runs the\n`Ssr` router to find a matching render function, serializes that function's\n`SlotValues` into the values envelope, and returns it packed as\n`(ptr << 32) | len`.\n\nA render function takes the `Request`, returns a filled `SlotValues` for a path\nit owns, or `null` to let the next registered renderer try.\n\n```ts\nimport { HtmlBuilder, Request, SlotValues, Ssr } from 'toiljs/server/runtime';\nimport { HASH, Slot } from './_ssr/hello.slots';\n\nfunction renderHello(req: Request): SlotValues | null {\n // The guest re-derives WHICH route this is from the path (the template name\n // is not in the request envelope), exactly as a @rest controller matches its\n // own prefix. req.path includes the query string, so match both forms.\n if (req.path != '/hello' && !req.path.startsWith('/hello?')) return null;\n\n const v = new SlotValues(HASH);\n\n v.setText(Slot.name, greetingName(req)); // escaped\n v.setRaw(Slot.blurb, 'Rendered at the <strong>edge</strong>.'); // verbatim\n\n const rows = new HtmlBuilder();\n for (let i = 0; i < services.length; i++) {\n const s = services[i];\n rows.raw('<li><strong>').text(s.name)\n .raw('</strong><span class=\"hello-region\">').text(s.region)\n .raw('</span></li>');\n }\n v.setRepeat(Slot.services, rows);\n\n return v;\n}\n\nSsr.register(renderHello); // side-effect registration\n```\n\n### Registration is manual; the import is load-bearing\n\n`ssr-codegen` generates ONLY the `Slot` enum and the `HASH`, it does **not**\nemit the render body and does **not** auto-register it. You write `renderHello`\nand call `Ssr.register(renderHello)` yourself.\n\nCrucially, `Ssr.register` runs as a **module side effect**, so the module must\nbe imported somewhere the build reaches. Non-surface files (a plain render\nmodule is not a `@rest`/`@service`/`@data` file) are **not** auto-discovered, so\nyou must `import './SsrHelloRender'` in `server/main.ts`. Forgetting the import\nmeans the renderer never registers, `Ssr.dispatch` returns `null`, and the route\nfalls back to the fail-safe 500.\n\n### What `render` does for a request the router misses\n\nIf no registered renderer matches, the `render` export emits a **fail-safe**\nenvelope: status 500 with a **zeroed** 32-byte hash and no slots (a malformed\nrequest envelope yields the same fail-safe with status 400). The edge rejects\nthe zero hash as a coherence mismatch, so a miss surfaces as a clean error\nrather than a corrupt page.\n\n---\n\n## 3. Reference: `SlotValues`\n\nConstruct it with the route's compiled-in hash: `new SlotValues(HASH)`. Each\nsetter targets a slot id (a `Slot` enum member); the **kind** determines how the\nedge splices it. All setters return `this` for chaining.\n\n| Method | Signature | Escaping | Use |\n| --- | --- | --- | --- |\n| `setText` | `setText(slotId, value: string)` | **React-escaped** | text content (safe by default) |\n| `setRaw` | `setRaw(slotId, html: string)` | **none (verbatim)** | raw HTML, *you* sanitize |\n| `setAttr` | `setAttr(slotId, value: string)` | **React-escaped** | an attribute value |\n| `setRepeat` | `setRepeat(slotId, rows: HtmlBuilder)` | per `HtmlBuilder` calls | a repeat region, pre-stamped row by row |\n| `setHeader` | `setHeader(name, value)` |, | a response header (e.g. `Cache-Control`, `Set-Cookie`) |\n| `setStatus` | `setStatus(code)` |, | the HTTP status (default 200) |\n\n`setText` and `setAttr` escape identically (React escapes text and attributes\nthe same way). Slot ids passed are the `Slot` enum members; AS enums are `i32`,\nso they pass without a cast and are narrowed to `u16` only at encode time.\n\n### `HtmlBuilder`\n\nAssembles a repeat region (or any HTML fragment) with the same escaping\nguarantees. Chain `raw` (verbatim template bytes) and `text` / `attr`\n(React-escaped values):\n\n```ts\nconst rows = new HtmlBuilder();\nfor (let i = 0; i < items.length; i++) {\n rows.raw('<li>').text(items[i]).raw('</li>'); // items[i] is escaped\n}\nv.setRepeat(Slot.list, rows);\n```\n\nYou are hand-writing the row markup, so it must match what `<Repeat>`'s child\nproduces for the same item, the build captured exactly that markup as the row\nsub-template, and the edge inserts your stamped rows verbatim at the region\noffset. Keep the **structure** the same across rows; only the leaf hole values\nvary.\n\n| Method | Signature | Escaping |\n| --- | --- | --- |\n| `raw` | `raw(s: string): HtmlBuilder` | verbatim |\n| `text` | `text(s: string): HtmlBuilder` | React-escaped |\n| `attr` | `attr(s: string): HtmlBuilder` | React-escaped (identical to `text`) |\n\n---\n\n## 4. Escaping (React-exact)\n\n`setText`, `setAttr`, and `HtmlBuilder.text` / `.attr` escape **exactly as\nReact does** (`react-dom/server`'s `escapeTextForBrowser`, regex `/[\"'&<>]/`),\nso the server-rendered markup and the client hydration agree byte-for-byte:\n\n```\n\" → &quot; & → &amp; ' → &#x27;\n< → &lt; > → &gt;\n```\n\nThe detail that bites: `'` becomes `&#x27;` (React's exact choice), **not**\n`&#39;`. If your escaping deviates from this by even one entity, `hydrateRoot`\nsees different markup and React throws a hydration mismatch and re-renders the\nsubtree. The guest's `escapeHtml` and the build's `reactEscapeHtml` are pinned\nto be byte-identical for exactly this reason.\n\n`setRaw` and `HtmlBuilder.raw` do **not** escape, they insert your bytes\nverbatim. That is the right tool for markup you produced or sanitized yourself\n(the same contract as `dangerouslySetInnerHTML`), and the wrong tool for\nanything derived from request input.\n\n---\n\n## 5. The build flow and generated artifacts\n\n`extractTemplates` (driven by `toiljs build`) does, for each `ssr = true` route:\n\n1. loads the route + its layout chain through a short-lived Vite SSR server;\n2. calls the `loader` with sample params to get representative data;\n3. renders the page under its layouts with the markers in **sentinel mode**\n (`__setSsrBuild(true)`), each marker emits a Private-Use-Area sentinel token\n instead of rendering normally;\n4. splices that into the built shell's `<div id=\"root\">` and adds the SSR marker\n `<template id=\"__toil_ssr\"></template>` (this is what the client `mount` looks\n for to switch to `hydrateRoot`);\n5. strips the sentinel tokens, records their **byte offsets**, and writes the\n artifacts.\n\n### Where the artifacts land\n\nFor a route named `<name>` (see below), under `build/client/_ssr/`:\n\n| File | Consumer | Contents |\n| --- | --- | --- |\n| `<name>.tmpl` | edge host (mmap'd) | the stripped static HTML shell, holes removed |\n| `<name>.slots` | edge host | the binary manifest (offsets, ids, kinds, tmpl_len, hash) |\n| `<name>.slots.ts` | guest build | the generated `Slot` enum + `HASH` AssemblyScript module |\n| `templates.json` | index | `[{ route, name, hash }]` for every extracted template |\n\nThe `.tmpl` and `.slots` are then **copied** into the edge host bundle at\n`hosts/edge/_tmpl/<name>.{tmpl,slots}`.\n\nThe build also writes the **server-importable** `Slot` + `HASH` module to\n`server/_ssr/<name>.slots.ts`, the one your `render` imports. It is generated\nand gitignored; never hand-edit it (see the two-pass note below).\n\n### Route name derivation (`routeTemplateName`)\n\nThe `<name>` is a file-safe slug of the route pattern: non-alphanumerics collapse\nto `_`, leading/trailing `_` are trimmed, empty → `index`.\n\n| Route pattern | `<name>` |\n| --- | --- |\n| `/hello` | `hello` |\n| `/` | `index` |\n| `/u/:name` | `u_name` |\n| `/blog/[id]` | `blog_id` |\n\n### The two-pass build: no hand-kept slots\n\nThe final extraction runs **after** the Vite client build (the built shell's\nhashed `<script>`/`<link>` tags are part of the template, so they must be in the\n`HASH`), but the server (guest wasm) is compiled **before** it. A naive build\ntherefore can't generate `<name>.slots.ts` in time for the `render` to import\nit. toiljs closes this with a **two-pass** build, so a clean build needs **zero\nhand-maintained slots**:\n\n1. **Slots pre-pass** (before the server build) renders every `ssr = true`\n route to its `Slot` enum + `HASH` and writes the server-importable module at\n `server/_ssr/<name>.slots.ts`. This is the file your `render` imports, it is\n **generated, gitignored, and never hand-edited**.\n2. The server compiles against that module.\n3. The client (Vite) builds.\n4. **Final extraction** re-renders against the real built shell and rewrites\n `server/_ssr/<name>.slots.ts` with the authoritative `HASH`. If the hash\n rotated since the pre-pass, the build recompiles the server **once** so the\n guest bakes the deployed hash.\n\nSo authoring an SSR route is just the route + the `render`; the `Slot` / `HASH`\nmodule is entirely build-managed. (On an unchanged rebuild the pre-pass reuses\nthe prior build's shell, so the hashes already match and step 4's recompile is\nskipped.)\n\n---\n\n## 6. Hash coherence and the values envelope\n\nEvery values envelope carries the guest's compiled-in 32-byte `HASH`. The edge\ncompares it against the deployed template's hash and **rejects a mismatch** with\na fail-safe 500 rather than splicing values into the wrong template. A mismatch\nmeans deploy skew: the guest was built against one version of the template and a\ndifferent one is deployed.\n\nThe hash is `sha256(tmpl || \\0 || canonicalManifest(slots))`, so any change to\nthe static HTML, a hole's id/kind, or the repeat nesting rotates it.\n\nThe guest serializes `SlotValues` to this little-endian, no-padding layout (the\nedge decodes it and splices against the template manifest):\n\n```\nu16 status\n[32] template_hash\nu16 n_headers\n for each header: u16 name_len, u16 val_len, name bytes, val bytes\nu16 n_slots\n for each value: u16 slot_id, u8 kind, u32 value_len, value bytes\n```\n\n`kind` is `0=text, 1=raw, 2=attr, 3=repeat`. The host keys values by `slot_id`\nand inserts each at the **manifest-fixed** offset, so the guest can never choose\n*where* bytes land, only what they are. If a value cannot be represented\n(a count or length overflows its field width, or the hash is the wrong size),\nthe encoder writes the same fail-safe 500/zero-hash envelope instead of corrupt\nbytes.\n\nThe matching `.slots` manifest the host reads is a 46-byte header\n(`\"TSLT\"` magic, u16 version, u16 flags, u32 tmpl_len, 32-byte hash, u16 n_slots)\nfollowed by 8-byte entries (`u32 offset, u16 slot_id, u8 kind, u8 reserved`).\n\n---\n\n## 7. Dev server and testing\n\n`toiljs dev` serves SSR routes the same way the edge does. It runs the **real**\n`render` export (`WasmServerModule.dispatchRender`), decodes the values\nenvelope, and splices the values into the route's template, so you get real\nserver-rendered HTML locally (`curl` a route, or view source), which then\nhydrates in place. The dev template is extracted once at startup against the\nlive (Vite-transformed) dev shell rather than a built one; a route's per-request\n**values** are always live, but a change to its **markup** needs a dev restart\nto re-extract. A fail-safe envelope (no renderer matched) falls back to client\nrendering.\n\nThe end-to-end test (`test/ssr-render.test.ts`) drives the same `dispatchRender`\npath directly: it calls `dispatchRender({ path: '/hello' })`, decodes the\nenvelope, asserts the slots, and splices against the built `hello.tmpl`.\n\n---\n\n## 8. Complete worked example: `/hello`\n\nThis is the full, copy-pasteable chain. All four files are real and tested.\n\n### `client/routes/hello.tsx` (the route)\n\n```tsx\nimport { Hole, Island, RawHtml, Repeat, useLoaderData } from 'toiljs/client';\n\nexport const ssr = true;\n\nexport const metadata: Toil.Metadata = {\n title: 'Edge SSR',\n description: 'A server-rendered greeting, filled at the edge.',\n};\n\ninterface Service { name: string; region: string; }\ninterface GreetingData {\n name: string;\n blurbHtml: string;\n services: Service[];\n}\n\n// Build-time sample data, only the SHAPE matters; the real per-request values\n// come from the SERVER render. The repeat sample needs at least one row.\nexport const loader = ({ params }: { params: Record<string, string> }): GreetingData => ({\n name: params.name ?? 'world',\n blurbHtml: 'Rendered at the <strong>edge</strong> from a tiny values envelope.',\n services: [\n { name: 'record', region: 'us-east' },\n { name: 'unique', region: 'eu-west' },\n { name: 'counter', region: 'ap-south' },\n ],\n});\n\nexport default function Hello(): React.JSX.Element {\n const d = useLoaderData<typeof loader>();\n return (\n <section className=\"hello\">\n <h1>Hello, <Hole id=\"name\">{d.name}</Hole>!</h1>\n\n <p className=\"hello-blurb\">\n <RawHtml id=\"blurb\" html={d.blurbHtml} as=\"span\" />\n </p>\n\n <h2>Service snapshot</h2>\n <ul className=\"hello-services\">\n <Repeat id=\"services\" each={d.services}>\n {(s: Service) => (\n <li>\n <strong><Hole id=\"svcName\">{s.name}</Hole></strong>\n <span className=\"hello-region\"><Hole id=\"svcRegion\">{s.region}</Hole></span>\n </li>\n )}\n </Repeat>\n </ul>\n\n <Island>\n <p className=\"hello-island\">\n Hydrated in your browser at {new Date().toLocaleTimeString()}.\n </p>\n </Island>\n </section>\n );\n}\n```\n\n### `server/_ssr/hello.slots.ts` (generated by the build; do not edit)\n\n```ts\n// AUTO-GENERATED by toil (edge SSR). Do not edit.\n\n/** Stable hole ids for this route's template (document order). */\nexport enum Slot {\n name = 0,\n blurb = 1,\n services = 2,\n}\n\n/** Coherence hash (32 bytes), written by the build's slots pre-pass; the host\n * rejects a response whose hash != the deployed template. */\nexport const HASH: StaticArray<u8> = [\n 0xcb, 0x12, 0x5e, 0x19, 0x46, 0x32, 0x58, 0x25, 0xd3, 0xf0, 0x44, 0xc5, 0x41, 0x0c, 0x34, 0x3b,\n 0x69, 0xd3, 0x62, 0xb3, 0x24, 0x25, 0x79, 0xc4, 0x76, 0x89, 0xfb, 0x25, 0x6e, 0x35, 0x02, 0x31,\n];\n```\n\n(Only the **top-level** holes get a `Slot` id, `name`, `blurb`, `services`. The\nnested `svcName` / `svcRegion` live inside the repeat row sub-template, which the\nguest stamps with `HtmlBuilder`, so they are not separate slots.)\n\n### `server/SsrHelloRender.ts` (the render)\n\n```ts\nimport { HtmlBuilder, Request, SlotValues, Ssr } from 'toiljs/server/runtime';\nimport { HASH, Slot } from './_ssr/hello.slots';\n\nclass Service {\n constructor(public name: string, public region: string) {}\n}\n\n/** Pull `?name=...`, defaulting to `world` (matches the route loader default). */\nfunction greetingName(req: Request): string {\n const q = req.path.indexOf('?');\n if (q < 0) return 'world';\n const parts = req.path.substring(q + 1).split('&');\n for (let i = 0; i < parts.length; i++) {\n if (parts[i].startsWith('name=')) {\n const v = parts[i].substring(5);\n return v.length > 0 ? v : 'world';\n }\n }\n return 'world';\n}\n\nfunction renderHello(req: Request): SlotValues | null {\n if (req.path != '/hello' && !req.path.startsWith('/hello?')) return null;\n\n const v = new SlotValues(HASH);\n\n // Text hole, React-escaped (so ?name=<a>&b is safe).\n v.setText(Slot.name, greetingName(req));\n\n // Raw hole, verbatim; a fixed, trusted blurb (no request data).\n v.setRaw(Slot.blurb, 'Rendered at the <strong>edge</strong> from a tiny values envelope.');\n\n // Repeat, stamp the captured row markup once per item. The row sub-template\n // is <li><strong>{svcName}</strong><span class=\"hello-region\">{svcRegion}</span></li>;\n // .text(...) escapes each nested hole exactly as React does.\n const services: Service[] = [\n new Service('record', 'us-east'),\n new Service('unique', 'eu-west'),\n new Service('counter', 'ap-south'),\n ];\n const rows = new HtmlBuilder();\n for (let i = 0; i < services.length; i++) {\n const s = services[i];\n rows.raw('<li><strong>').text(s.name)\n .raw('</strong><span class=\"hello-region\">').text(s.region)\n .raw('</span></li>');\n }\n v.setRepeat(Slot.services, rows);\n\n return v;\n}\n\n// Side-effect registration: main.ts imports this module so the build compiles\n// it in and this renderer joins the SSR router.\nSsr.register(renderHello);\n```\n\n### `server/main.ts` (the load-bearing import)\n\n```ts\nimport { Server } from 'toiljs/server/runtime';\n// ... other surface imports ...\n\n// Edge SSR: importing the render module compiles it in and self-registers its\n// /hello renderer. Without this import the renderer never registers.\nimport './SsrHelloRender';\n\nServer.handler = () => new AppHandler();\n\nexport * from 'toiljs/server/runtime/exports'; // surfaces `handle` AND `render`\n```\n\nThe spliced first-paint HTML for `GET /hello` is byte-identical to what React\nrenders for the same data:\n\n```html\n<section class=\"hello\"><h1>Hello, world!</h1>\n<p class=\"hello-blurb\"><span>Rendered at the <strong>edge</strong> from a tiny values envelope.</span></p>\n<h2>Service snapshot</h2>\n<ul class=\"hello-services\">\n<li><strong>record</strong><span class=\"hello-region\">us-east</span></li>\n<li><strong>unique</strong><span class=\"hello-region\">eu-west</span></li>\n<li><strong>counter</strong><span class=\"hello-region\">ap-south</span></li>\n</ul></section>\n```\n\nThe `<Island>` is empty here (no first paint); it fills in after hydration.\n\n---\n\n## 9. Pitfalls and debugging\n\n- **Route skipped at build (warning, no SSR).** The route or a layout above it\n threw under static markup, almost always a router hook (`useRouter`,\n `usePathname`, …) or a browser-only API rendered outside an `<Island>`. The\n build prints `toil: SSR skipped <pattern> (...)` and the route falls back to\n client rendering. Move the offending content into an `<Island>`.\n\n- **Hash mismatch / clean 500 after editing a template.** Any change to the\n page's static markup, a hole id/kind, or the repeat structure rotates the\n `HASH`. The host rejects a stale guest hash. A normal `toiljs build`\n regenerates `server/_ssr/<name>.slots.ts` and rebakes the guest, so this only\n surfaces from a partial or stale deploy (a guest built against a different\n template than the one deployed), never from hand-copied slots.\n\n- **Hydration mismatch (flash / React re-render in the browser).** Two common\n causes. (1) The client **loader** does not reproduce the values the server\n `render` stamped. Hydration re-renders the route with the loader's data, so for\n any request-derived hole the loader must derive the **same** value the server\n `render` does (e.g. read the same `?query` / param). The two are separate\n sources (the client loader is TypeScript; the server `render` is the wasm\n guest), so keeping them in sync is the author's contract; if the client cannot\n reproduce a value, put that content in an `<Island>`. (2) A marker (or a\n non-static node) rendered outside an `<Island>`, or hole escaping that does not\n match React's (e.g. emitting `&#39;` instead of `&#x27;`, or using `setRaw`\n where the client would escape). Keep dynamic text in `<Hole>` / `setText` and\n client-only content in `<Island>`.\n\n- **Route renders client-side only even though `ssr = true`.** You forgot to\n `import './SsrHelloRender'` in `server/main.ts`, so `Ssr.register` never ran\n and `Ssr.dispatch` returns `null`. Add the import. (Plain render modules are\n not auto-discovered the way `@rest`/`@service` files are.)\n\n- **`setRaw` injecting unsanitized request data.** `setRaw` is verbatim, never\n pass it anything derived from request input you have not sanitized. Use\n `setText` for request-derived text.\n\n- **Empty `<Repeat>` sample at build.** The build captures the row sub-template\n from the **first** sample row. If your build-time `loader` returns an empty\n array for a `<Repeat>`, there is no row to capture. Give the build sample at\n least one representative row.\n</content>\n</invoke>\n",
15
+ "rpc.md": "# RPC and the generated client\n\nThe server build with `--rpcModule shared/server.ts` scans your decorated\nsurface (`@data`, `@user`, `@service`/`@remote`, `@rest`) and emits one\nTypeScript module: a typed `Server` proxy, the `@data` codec classes, the REST\nfetch client, and the `getUser()` accessor. The client imports that file and\ncalls the server with full type-safety and editor autocomplete. The file is\nregenerated on every server build, so it never drifts from the server.\n\n```sh\ntoilscript --target release --rpcModule shared/server.ts\n```\n\n## `@service` and `@remote`\n\nA `@service` class exposes its `@remote` methods as callable RPC. A top-level\n`@remote` function is exposed directly.\n\n```ts\n@service\nclass Stats {\n @remote\n public playerCount(): i32 { return store.size; }\n}\n\n@remote\nfunction ping(n: i32): i32 { return n + 1; }\n```\n\nThe generated client surfaces these on the global `Server` proxy. A service is\nkeyed by its class name with the first letter lowercased (`Stats` → `stats`):\n\n```ts\nawait Server.stats.playerCount(); // Promise<number>\nawait Server.ping(42); // Promise<number>\n```\n\nArguments and return values are `@data`-typed; scalars map to TS as below.\n\n## The generated `Server` surface\n\n`shared/server.ts` declares a global `Server` whose shape is, schematically:\n\n```ts\ndeclare global {\n const Server: {\n // top-level @remote functions\n ping(n: number): Promise<number>;\n\n // @service classes (keyed by lowercased name)\n readonly stats: {\n playerCount(): Promise<number>;\n };\n\n // @rest controllers, under REST\n readonly REST: {\n readonly players: {\n get(args: { params: { id: string | number | bigint }; query?: …; headers?: … }): Promise<Response>;\n create(args: { body: NewPlayer; query?: …; headers?: … }): Promise<Player>;\n };\n };\n };\n}\n```\n\n### Type mapping (ToilScript → TypeScript)\n\n| ToilScript | TypeScript |\n| --- | --- |\n| `u8`,`u16`,`u32`,`i8`,`i16`,`i32`,`f32`,`f64` | `number` |\n| `u64`,`i64`,`u128`,`i128`,`u256`,`i256` | `bigint` |\n| `bool` | `boolean` |\n| `string` | `string` |\n| a `@data` class `T` | `T` (the emitted class) |\n| `T[]` | `T[]` |\n\n64-bit-and-larger integers are `bigint` on the client and travel as decimal\nstrings on the JSON wire, so they are exact at any magnitude.\n\n### Emitted `@data` classes\n\nEach `@data` (and `@user`) class becomes an exported TS class with the fields, a\ndefaulted constructor, and the matching codec:\n\n```ts\nexport class Player {\n constructor(public username = '', public admin = false, public score = 0n) {}\n encodeInto(w: DataWriter): void { /* … */ }\n encode(): Uint8Array { /* dataId prefix + fields */ }\n static decodeFrom(r: DataReader): Player { /* … */ }\n static decode(buf: Uint8Array): Player { /* … */ }\n static dataId(): number { /* FNV-1a of \"Player\" */ }\n static fromJSONValue(v: any): Player { /* revive, 64-bit from strings */ }\n toJSONValue(): any { /* 64-bit as decimal strings */ }\n}\n```\n\nThe codec is byte-compatible with the server's `@data` codec, so binary bodies\nround-trip exactly between client and wasm.\n\n## The REST fetch client\n\nEvery `@rest` route also gets a typed fetch wrapper under `Server.REST.<key>`,\nkeyed by the controller name lowercased. The call argument is an object:\n\n```ts\nServer.REST.players.create({\n body: new NewPlayer('alice'), // present iff the route takes a body\n // params: { id: 7 }, // present iff the path has :params\n query: { ref: 'home' }, // optional\n headers: { 'x-trace': traceId }, // optional\n});\n```\n\n- If the route has no params and no body, the whole argument is optional\n (`args?`).\n- The wrapper builds the URL (substituting `:params`, appending `query`),\n `fetch`es with `credentials` as configured, throws on a non-2xx status, and\n decodes the response into the route's return type.\n- A route declared to return `Response` resolves to the raw `fetch` `Response`,\n so you can stream or inspect headers yourself.\n\n```ts\nconst player = await Server.REST.players.create({ body: new NewPlayer('alice') });\n// ^? Player\n```\n\n## `getUser()`\n\nWhen the server declares a `@user` class, the generated module also exports a\ntyped, no-argument `getUser()` that reads the readable companion cookie and\ndecodes it with the generated codec:\n\n```ts\nimport { getUser } from './shared/server';\n\nconst user = getUser(); // Account | null, fully typed\n```\n\nThis is **display-only**: the server re-verifies the signed session on every\n`@auth` request. See [Auth](./auth.md) for the full picture.\n\n## Notes\n\n- `shared/server.ts` is generated; never edit it by hand. Re-run the server\n build (or `toiljs dev`, which does it on save) to refresh it.\n- The `Server` proxy is declared as an ambient global on the client; the runtime\n implementation is provided by toiljs. The REST client and `getUser` are real\n exported values in the generated module.\n",
16
+ "data.md": "# Data codec (`@data`)\n\n`@data` turns a plain class into a typed, versionable value with a deterministic\nbinary codec and a JSON codec. It is the backbone of request/response bodies,\nRPC arguments, sessions, and anything you persist. The same class becomes a\nfully typed client type in the generated `shared/server.ts` (see\n[RPC](./rpc.md)).\n\n```ts\n@data\nclass Player {\n username: string = '';\n admin: bool = false;\n score: u64 = 0;\n}\n```\n\nFrom that the compiler synthesizes, on the class:\n\n- `encode(): Uint8Array` / `static decode(buf): T`, the binary codec (with a\n 4-byte type id prefix).\n- `encodeInto(w: DataWriter)` / `static decodeFrom(r: DataReader)`, the codec\n without the type-id frame, for nesting.\n- `toJSON()` / `static fromJSON(v)`, the JSON codec (64-bit-and-larger integers\n as decimal strings, so they survive `JSON.parse` exactly).\n- `static dataId(): u32`, a stable FNV-1a hash of the class name, written as the\n type-id prefix by `encode()`.\n\nFields may be scalars (`u8`..`u256`, `i8`..`i256`, `f32`, `f64`, `bool`),\n`string`, a nested `@data` class, or an array `T[]` of any of these. Give every\nfield a default; the generated decoder and the client constructor use them.\n\n## Using `@data` in routes\n\nIn a **JSON** route, a `@data` parameter is revived from the parsed body and a\n`@data` return value is serialized with `toJSON()`. In a **Binary** route, the\nparameter is `decode`d from the raw body and the return value is `encode`d. The\nroute's stream mode (see [Routing](./routing.md#data-streams)) picks which.\n\n```ts\n@post('/') // JSON route\npublic create(input: NewPlayer): Player { /* input from JSON, Player to JSON */ }\n\n@route({ method: Methods.POST, path: '/blob', stream: DataStream.Binary })\npublic blob(input: FileData): FileResult { /* input.decode, result.encode */ }\n```\n\n## The binary codec: `DataWriter` / `DataReader`\n\nWhen you need to lay out bytes yourself, custom bodies, session payloads,\nchallenge messages, use the codec directly. It lives in the `data` module:\n\n```ts\nimport { DataWriter, DataReader } from 'data';\n```\n\nThe codec has a byte-for-byte identical TypeScript implementation in\n`toiljs/io` (`src/io/codec.ts`), so the client can read and write the exact same\nwire format the wasm guest does.\n\n### `DataWriter`\n\nEvery writer method returns the writer for chaining.\n\n| Method | Signature | Wire format |\n| --- | --- | --- |\n| `writeU8` / `writeI8` | `(v): DataWriter` | 1 byte |\n| `writeU16` / `writeI16` | `(v): DataWriter` | 2 bytes, little-endian |\n| `writeU32` / `writeI32` | `(v): DataWriter` | 4 bytes, LE |\n| `writeU64` / `writeI64` | `(v): DataWriter` | 8 bytes, LE |\n| `writeF32` / `writeF64` | `(v): DataWriter` | 4 / 8 bytes, IEEE-754 LE |\n| `writeBool` | `(v): DataWriter` | 1 byte (`1`/`0`) |\n| `writeBytes` | `(b: Uint8Array): DataWriter` | `u32` length (LE) + raw bytes |\n| `writeString` | `(s: string): DataWriter` | `u32` length (LE) + UTF-8 bytes |\n| `writeU128` / `writeI128` | `(v): DataWriter` | two `u64` limbs (lo, hi) |\n| `writeU256` / `writeI256` | `(v): DataWriter` | four `u64` limbs (lo1, lo2, hi1, hi2) |\n| `length` | `(): i32` | bytes written so far |\n| `toBytes` | `(): Uint8Array` | an exact-length copy of the buffer |\n\n### `DataReader`\n\nReads are bounds-safe: an over-read never traps. It returns a zero/empty default\nand sets the public `ok` flag to `false`. Check `ok` after a sequence of reads\nto detect a truncated or malformed buffer.\n\n| Method | Signature | On over-read |\n| --- | --- | --- |\n| `readU8` / `readI8` | `(): u8 / i8` | `0` |\n| `readU16`..`readU64`, `readI16`..`readI64` | `(): integer` | `0` |\n| `readF32` / `readF64` | `(): f32 / f64` | `0` |\n| `readBool` | `(): bool` | `false` |\n| `readBytes` | `(): Uint8Array` | empty array |\n| `readString` | `(): string` | `\"\"` |\n| `readU128`/`readI128`/`readU256`/`readI256` | `(): bignum` | `0` |\n| `remaining` | `(): i32` | bytes left unread |\n| `ok` | `bool` (field) | `false` once any read over-ran |\n\n### Example\n\n```ts\nimport { DataWriter, DataReader } from 'data';\n\n// Write: u8 version, str name, u64 score, bytes blob\nconst out = new DataWriter()\n .writeU8(1)\n .writeString('alice')\n .writeU64(1234)\n .writeBytes(payload)\n .toBytes();\n\n// Read it back\nconst r = new DataReader(out);\nconst version = r.readU8();\nconst name = r.readString();\nconst score = r.readU64();\nconst blob = r.readBytes();\nif (!r.ok) return Response.badRequest('truncated');\n```\n\n## Notes\n\n- **Endianness.** The AS guest codec is little-endian. The TypeScript `toiljs/io`\n codec defaults to little-endian and also accepts a per-call `be` flag for\n big-endian network formats; keep both ends on the same setting.\n- **Field order is the format.** The binary layout is exactly the field\n declaration order. Reordering fields, or changing a type, is a breaking format\n change. Add new fields at the end and bump a leading version byte if you need\n to evolve a hand-rolled payload.\n- **`encode()` carries a type id.** The 4-byte `dataId()` prefix lets a decoder\n confirm it is reading the type it expects. `encodeInto`/`decodeFrom` skip the\n frame for nesting one `@data` value inside another.\n",
17
+ "caching.md": "# Caching\n\ntoiljs can cache a response at the edge (shared, across users) and instruct the\nbrowser to cache it too. You opt in per route, either declaratively with the\n`@cache` decorator or imperatively with `Response.cache(...)`. The edge keys a\ncached entry by host, method, path, and body hash, and honors a per-entry TTL.\n\n## `@cache` decorator\n\nAnnotate a route method; the compiler appends the cache directive to whatever\n`Response` the route returns, so it composes with every return shape (a\n`Response`, a `void` 204, or an auto-encoded `@data` value).\n\n```ts\n@cache(60) // 60 minutes at the edge\n@cache(60, 300) // + 5 minutes (300s) in the browser\n@cache(60, 300, true) // + private scope (per-user caches only)\n@cache(60, 300, true, true) // + cache even for authenticated requests\n@get('/leaderboard')\npublic top(): Standings { /* … */ }\n```\n\nArguments must be integer or boolean literals; a non-literal argument makes the\ndecorator degrade safely to \"not cached\" rather than miscompile.\n\n## `Response.cache(...)`\n\nThe same controls are available imperatively, which is what `@cache` lowers to:\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`cacheFor(minutes)` is the common shorthand for \"edge only, no browser caching\":\n\n```ts\nreturn Response.bytes(blob).cacheFor(5);\n```\n\n## Parameters\n\n| Parameter | Meaning |\n| --- | --- |\n| `edgeTtlMinutes` | How long the edge may serve the cached response. Clamped to a 24-hour maximum. |\n| `browserTtlSeconds` | `max-age` for the browser. `0` (default) means the browser does not cache. |\n| `privateScope` | Marks the response `private`: only per-user caches (the browser), never a shared edge/CDN cache. |\n| `allowAuth` | Permit caching a response to an authenticated request. Off by default (see safety rails). |\n\n## Safety rails\n\nThe cache layer refuses to store anything unsafe, regardless of the directive:\n\n- **5xx** responses are never cached, a server error is transient, and `@cache`\n wraps the whole route, so a `@cache`d route that hits a blip returns its 500\n carrying the directive; caching it would serve the failure for the full TTL.\n **2xx, 3xx, and 4xx are cacheable** (a redirect or a `404`/`410` is a\n deterministic function of the request key);\n- a response that sets a **`Set-Cookie`** is never cached;\n- a response to an **authenticated** request is not cached unless you pass\n `allowAuth = true`, this prevents one user's personalized response from being\n served to another;\n- the edge TTL is **clamped to 24 hours**.\n\nBecause `@auth` guards and body-decode run before the cache directive is applied,\nan unauthorized request is rejected with 401 before anything is cached, and a\ncached entry is only ever produced from a handler that actually ran.\n\nCaching is **always opt-in.** A response with no `Toil-Cache-Control` directive\n(i.e. no `@cache` / `Response.cache(...)`) is never stored, there is no blind\n\"cache every GET\" mode, because an automatic window cannot tell a personalized\nresponse from a public one and would key it without a per-user component.\n\n## Memory bounds and disk spill\n\nThe edge cache is per-core and hard-capped so it can never exhaust node memory.\nIt has two tiers:\n\n- **RAM tier**, small, short-TTL responses. Bounded by a per-core byte budget\n (each core holds at most ~128 MB) plus an entry-count cap; an insert that would\n exceed the budget drops expired entries first, then evicts the soonest-to-expire\n ones. A response over ~256 KB does not go in the RAM tier.\n- **Disk tier (spill)**, when the operator enables `--spill-dir`, a **big**\n (over the ~256 KB RAM cap) or **long-TTL** (≥ 10 min) cacheable response is\n written to disk instead and served back zero-RAM via a memory map, the same way\n static files are served. This keeps the RAM tier for the hot working set while\n still caching large bodies and long-lived entries. Writes (and unlinks) are\n offloaded to a sibling thread so they never stall the request path; a separate\n per-core disk budget caps total spilled bytes, with the same expiry + eviction.\n If spill is not enabled, a big response is simply not cached (reported as not\n stored by the `Toil-Cache` tag).\n\nFrom a tenant's point of view nothing changes: you still just set a\n`Toil-Cache-Control` directive (via `@cache` / `Response.cache(...)`). The edge\ndecides RAM vs disk; both honor the same TTL and the same safety rails above.\nExpiry is enforced on read (a past-TTL entry is a miss) and reclaimed on the next\ninsert that needs room. Nothing persists across a process restart.\n\n## Choosing TTLs\n\n- Public, slow-changing data (a leaderboard, a catalog): a few minutes of edge\n TTL plus a short browser TTL removes most of the load.\n- Per-user data: set `privateScope` so it never lands in a shared cache, and\n prefer a small or zero edge TTL.\n- Anything with a `Set-Cookie` or behind `@auth`: leave it uncached unless you\n have thought through `allowAuth` and are certain the body is identical for\n every authorized caller.\n",
18
+ "ratelimit.md": "# Rate limiting\n\nThe `@ratelimit` decorator throttles **any** `@rest` route, a login, a signup, a\npublic API, an email trigger, anything. It is enforced at the edge, before your\nhandler runs, and keyed by default on the connecting client's **unspoofable** IP,\nso it works as an abuse / brute-force control out of the box.\n\nIt composes with the other route decorators and is independent of email or auth.\n\n## Using `@ratelimit`\n\nAdd it to a route alongside the verb decorator:\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('auth')\nclass Auth {\n // At most 5 login attempts per 60 seconds per client IP.\n @ratelimit(RateLimit.SlidingWindow, 5, 60)\n @post('/login')\n login(ctx: RouteContext): Response {\n // ... only runs if under the limit ...\n return Response.text('ok\\n');\n }\n}\n```\n\n`@ratelimit(strategy, limit, window)`:\n\n- **`strategy`**, a `RateLimit` value (ambient global, no import):\n `RateLimit.FixedWindow`, `RateLimit.SlidingWindow`, or `RateLimit.TokenBucket`.\n- **`limit`** and **`window`**, integer literals whose meaning depends on the\n strategy (see below).\n\nWhen a request is over the limit the edge returns **`429 Too Many Requests`**\nwith a **`Retry-After`** header (whole seconds), and your handler never runs. The\nguard runs **before `@auth`**, so unauthenticated floods are limited too.\n\n> Both arguments must be **integer literals** and the strategy a `RateLimit`\n> member (or a bare integer tag). A malformed decorator emits no guard rather\n> than miscompiling, the same fail-safe rule as `@cache`.\n\n## Strategies\n\n| Strategy | `limit`, `window` mean | Behavior |\n| --- | --- | --- |\n| `FixedWindow` | `limit` events per `window` seconds | Cheapest. Counts in aligned wall-clock buckets; a caller hammering a boundary can briefly get up to ~2× `limit` across two adjacent windows. |\n| `SlidingWindow` | `limit` events per `window` seconds | Smooths the fixed-window boundary by weighting the previous window. Best general choice for \"N per period\". |\n| `TokenBucket` | `limit` = burst size, `window` = refill **per second** | Allows an initial burst of `limit`, then a steady `window` tokens/sec. Good for bursty-but-bounded APIs. |\n\nExamples:\n\n```ts\n// 100 requests / minute, smoothed:\n@ratelimit(RateLimit.SlidingWindow, 100, 60)\n\n// Burst of 20, then 5 per second sustained:\n@ratelimit(RateLimit.TokenBucket, 20, 5)\n\n// Exactly 3 per hour, cheapest:\n@ratelimit(RateLimit.FixedWindow, 3, 3600)\n```\n\n## How requests are keyed\n\nBy default the limiter keys on the **client IP**, specifically the TCP peer\naddress the edge observed (`ctx.clientIp()`), **not** a header like\n`X-Forwarded-For`, which a client can forge. That makes it a real abuse control:\na caller can't reset their bucket by spoofing a header.\n\nThe count is **exact across all 14 edge workers** (a given IP always maps to one\nauthoritative shard), so the limit is global per route, not per worker. Only\nroutes that opt in with `@ratelimit` ever pay anything, the lock-free fast path\nfor everything else is untouched.\n\n> Each rate-limited route has its own independent limiter, a limit on `/login`\n> does not consume the budget of `/signup`.\n\n## Notes and limits\n\n- **Route-level only.** Put `@ratelimit` on each route you want limited; there is\n no controller-wide form yet (unlike `@auth`).\n- **Keyed on IP.** The decorator keys on the peer IP today. (A per-user / custom\n key, limiting by account instead of IP, exists in the runtime but is not yet\n exposed through the decorator.)\n- **In dev.** `toiljs dev` runs a single-process mirror of the same three\n strategies, so a limited route behaves the same locally as on the edge.\n\n## See also\n\n- [Email](./email.md), `@ratelimit` pairs well with email triggers (verification\n codes, password resets) to blunt abuse.\n- [Auth, sessions, and `@user`](./auth.md), `@ratelimit` runs before the `@auth`\n guard, so it protects the login itself.\n",
19
+ "auth.md": "# Auth, sessions, and `@user`\n\ntoiljs ships **Toil PQ-Auth**: a post-quantum password login where the password\nnever leaves the browser and the server stores only public verifier material.\nOn top of it sit HMAC-signed session cookies, a `@auth` route guard, and a\n`@user` type that makes the signed-in user available - fully typed, no type\nargument - on both the server (`AuthService.getUser()`) and the generated client\n(`getUser()`).\n\n> **Status.** PQ-Auth is a hybrid construction (see [What it is](#what-it-is)).\n> It is opt-in and **not hardened to production yet** - the example storage is a\n> dev stand-in, the secrets are dev placeholders, and the composition has not had\n> an external cryptographic review. See [`docs/auth-todo.md`](./auth-todo.md) for\n> the remaining work before it backs real credentials.\n\n`AuthService` is an ambient global (no import). The pieces:\n\n- **`@user`** - declares the authenticated user's shape and registers it as *the* user type.\n- **`@auth`** - guards a route (or a whole `@rest` class): a valid session is required or `401`.\n- **`AuthService`** - the server runtime: the PQ-Auth crypto, plus mint/read/clear a session and `getUser()`.\n- **client `Auth` + generated `getUser()`** - run the login from the browser and read the user for display.\n\n---\n\n## What it is\n\nA password is a weak, low-entropy secret. PQ-Auth turns it into a strong,\n**post-quantum** credential and proves possession to the server without the\nserver (or anyone on the wire, or a future quantum adversary) ever seeing\nanything they can replay. It is built from three independent ideas, each\ndefending a specific attack:\n\n| Layer | Primitive | Defends against |\n| --- | --- | --- |\n| **Keyed salt** | OPRF (RFC 9497, ristretto255-SHA512) | A breached server (or a passive observer) **precomputing** a password dictionary. |\n| **Credential** | Argon2id → ML-DSA-44 (FIPS 204) keypair | The password ever crossing the wire; a stolen verifier being usable without an expensive per-guess attack. |\n| **Mutual auth + key** | ML-KEM-768 (FIPS 203) | A phishing/MITM server impersonating the real one; a session with no key to bind to. |\n\nThe password is stretched into a signing keypair entirely client-side; only the\n**public** key is registered. Login is a challenge-response signature *plus* a\nkey encapsulation, so both parties authenticate each other. Authentication\n(ML-DSA) and key agreement (ML-KEM) are post-quantum; the keyed-salt OPRF is\nclassical ristretto255 (the one non-PQ layer - a quantum break of it degrades to\na post-breach offline attack, no worse than a plain salt, while defeating\nprecomputation for everyone else).\n\n### Why a keyed salt (the OPRF)\n\nA normal salted hash (`Argon2id(password, salt)`) lets anyone who learns the\nsalt - including a future attacker who simply asks the login endpoint for it -\n**precompute** a dictionary offline and crack the stored verifier the instant\nthey breach it. PQ-Auth replaces the salt with the output of a **server-keyed**\nOPRF:\n\n```\noprfOutput = OPRF_finalize(password, OPRF_evaluate(k_user, blind(password)))\nseed = Argon2id(oprfOutput, salt)\n```\n\nThe client **blinds** the password (so the server learns nothing about it),\nthe server **evaluates** the blinded element under a per-user key `k_user`\nderived from a server-secret master seed, and the client **unblinds** to recover\na deterministic, high-entropy `oprfOutput`. Because `k_user` is a server secret,\n**no offline work is possible until that secret leaks** - precomputation is\nimpossible, and even a passive observer who captures a login learns nothing.\nThe per-user key (`k_user = DeriveKeyPair(masterSeed, username)`) means two\naccounts with the same password get different outputs - no cross-account\npassword-equality leak.\n\n### Why a password-derived signing key\n\n`seed = Argon2id(oprfOutput, salt)` deterministically expands into an\n**ML-DSA-44 keypair**. The client registers only the 1312-byte **public** key;\nthe secret key and seed are zeroized the instant signing is done. The server\nstores the public key as a verifier and can only ever *verify* - it never holds\na secret (`crypto.mldsa_verify` is verify-only on the edge). A full server breach\nyields public keys, not passwords; recovering a password still requires an\noffline Argon2id dictionary attack **and** the leaked OPRF master seed.\n\n### Why ML-KEM (mutual auth + session key)\n\nA signature proves the *client* to the server, but nothing proves the *server*\nto the client. PQ-Auth pins the server's static **ML-KEM-768 public key** in the\nclient. At login the client **encapsulates** a shared secret to that key; only\nthe genuine server (holding the matching secret key) can **decapsulate** it. Both\nsides derive the same session key and the server returns a confirmation tag the\nclient checks - so a phishing/MITM server that lacks the secret key cannot\ncomplete the handshake.\n\n---\n\n## Flow at a glance\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant C as Browser\n participant S as Edge wasm\n participant DB as Your store\n\n rect rgb(14, 21, 32)\n Note over U,DB: Register, password never leaves the browser\n U->>C: Auth.register(username, password)\n C->>S: POST /auth/register/start (username, blinded)\n S->>S: OPRF-evaluate under k_user, issue salt and KDF params\n S-->>C: salt, params, evaluated\n C->>C: finalize OPRF, Argon2id, ML-DSA-44 keypair, sign PoP\n C->>S: POST /auth/register/finish (username, publicKey, regProof)\n S->>S: verifyRegister(publicKey, PoP)\n S->>DB: store Account (username, salt, params, publicKey)\n S-->>C: ok\n end\n\n rect rgb(22, 15, 31)\n Note over U,DB: Login, mutual authentication\n U->>C: Auth.login(username, password)\n C->>S: POST /auth/login/start (username, blinded)\n S->>DB: store challenge (cid, nonce, iat, exp)\n S-->>C: cid, aud, salt, params, nonce, iat, exp, evaluated\n C->>C: finalize OPRF, derive seed, ML-DSA keypair, ML-KEM encapsulate, sign M\n C->>S: POST /auth/login/finish (cid, ct, signature)\n S->>DB: atomic consume challenge(cid)\n S->>S: rebuild M, verifyLogin, decapsulate, derive K, build confirm\n alt signature valid\n S-->>C: ok, sessionToken, serverConfirm, Set-Cookie\n C->>C: re-derive K, check serverConfirm, server authenticated\n else invalid or unknown user\n S-->>C: 401 generic, anti-enumeration\n end\n end\n\n rect rgb(13, 25, 18)\n Note over U,DB: Guarded request, the @auth guard\n U->>C: open or call an @auth route\n C->>S: request, cookies sent automatically\n S->>S: @auth verifies HMAC and expiry on __Host-toil_sess\n alt valid session\n S->>S: handler runs, AuthService.getUser() returns the @user\n S-->>C: 200\n else missing or invalid\n S-->>C: 401 before handler and body-decode\n end\n end\n```\n\n### The signed transcript\n\nThe login message `M` the client signs (and the server rebuilds from its own\nstored values) is a single fixed binary layout - no JSON, no version negotiation:\n\n```\nu8 tag = 1\nstr sub (username)\nstr aud (service audience; server constant)\nbytes cid (challenge id)\nbytes nonce (32 random bytes, server-issued)\nu64 iat, u64 exp (challenge validity window)\nbytes ct (ML-KEM ciphertext)\nu32 memKiB, iterations, parallelism (Argon2id params)\nbytes serverKemKeyId (SHA-256 of the server KEM public key)\n```\n\nSigning over all of this binds the login to: the exact challenge (so it can't be\nreplayed - and `cid` is consumed atomically), the **ciphertext** (so a MITM can't\nswap the key encapsulation), the **KDF params** (so a downgrade can't be slipped\npast the signature), and the **server key identity** (so it commits to which\nserver key was used). The mutual-auth tag is then:\n\n```\nK = HMAC-SHA256(sharedSecret, \"toil-session-key-v1\" || SHA-256(M))\nconfirm = HMAC-SHA256(K, \"toil-server-confirm-v1\" || SHA-256(M))\n```\n\n`K` is the authenticated session key, derived from the KEM shared secret and\nbound to the transcript. Only a server that decapsulated correctly derives the\nsame `K`, so the client checking `confirm` proves the server's identity. (`K` is\nthe handle for future channel binding; binding the session *cookie* to the\ntransport needs the TLS exporter, which the wasm guest can't see - a follow-up.)\n\n### Anti-enumeration\n\n`login/start` returns a fully-formed response for **every** username: it always\nOPRF-evaluates (a real `k_user` for known users, a deterministic decoy key for\nunknown ones) and returns a **deterministic per-user salt** and constant params.\nKnown and unknown users are byte-indistinguishable, and the eventual signature\nsimply fails for a non-account. Failures return one generic `401`.\n\n---\n\n## `@user`\n\nMark one class per program as the user type. It becomes a `@data` codec (so it\nserializes into the session) and the return type of `getUser()` everywhere.\n\n```ts\n@user\nclass Account {\n username: string = '';\n admin: bool = false;\n score: u64 = 0;\n}\n```\n\nThere is exactly one `@user` per program; a second is a compile error.\n\n## `@auth`\n\nPut `@auth` on a route, or on the `@rest` class to guard every route in it. The\ngenerated dispatcher checks for a valid, unexpired session **before** the handler\nruns (and before any body-decode or cache write); without one it returns `401`.\n\n```ts\n@rest('session')\nclass Session {\n @auth\n @get('/me')\n public me(): Response {\n const u = AuthService.getUser(); // Account | null, auto-typed\n if (u == null) return Response.text('no session\\n', 401);\n return Response.bytes(new DataWriter()\n .writeString(u.username).writeBool(u.admin).writeU64(u.score).toBytes());\n }\n}\n```\n\n`@auth` on the class form guards all routes in it.\n\n## `AuthService` (server)\n\nA global namespace. Session methods read the ambient request\n(`Server.currentRequest`), so `getUser()`/`hasSession()` take no argument and are\nonly meaningful during a dispatch.\n\n### PQ-Auth crypto\n\nStartup config (call once in `main.ts`; identical on every edge instance; never\nin a client bundle):\n\n| Member | Notes |\n| --- | --- |\n| `setSecret(secret)` | HMAC secret for session cookies. |\n| `setOprfSeed(seed)` | 32-byte OPRF master seed; per-user keys derive from this + the username. |\n| `setServerKemSecretKey(sk)` | Server static ML-KEM-768 secret key (2400 B) used to decapsulate. |\n| `setServerKemPublicKey(pk)` | The matching public key (1184 B) for `serverKemKeyId`; it is embedded in `sk` at bytes `[1152, 2336)`, so you can pass `sk.slice(1152, 2336)`. |\n\nPer-request building blocks:\n\n| Member | Notes |\n| --- | --- |\n| `oprfEvaluate(username, blinded)` | OPRF server step: blind-evaluate under `k_user` derived from the seed + username. Returns the 32-byte evaluated element. |\n| `mlkemDecapsulate(ct)` | Recover the 32-byte shared secret from the client ciphertext with the server secret key. |\n| `buildLoginMessage(sub, aud, cid, nonce, iat, exp, ct, memKiB, iterations, parallelism, serverKemKeyId)` | The canonical login message `M`. Call it with the server's **own** stored values, never client-echoed fields. |\n| `verifyLogin(publicKey, message, signature)` | Verify the ML-DSA login signature under `LOGIN_CONTEXT`. |\n| `serverKemKeyId()` | `SHA-256(serverKemPublicKey)` - the key id bound into `M`. |\n| `sha256(data)` | SHA-256, for the transcript hash. |\n| `deriveSessionKey(sharedSecret, transcriptHash)` | `K = HMAC(sharedSecret, SESSION_KEY_LABEL || transcriptHash)`. |\n| `serverConfirmTag(sessionKey, transcriptHash)` | The mutual-auth tag `HMAC(K, SERVER_CONFIRM_LABEL || transcriptHash)`. |\n| `buildRegisterMessage(username, publicKey)` / `verifyRegister(...)` | Registration proof-of-possession (under `REGISTER_CONTEXT`). |\n| `LOGIN_CONTEXT` / `REGISTER_CONTEXT` | `qauth:login:v1` / `qauth:register:v1` - FIPS 204 signing contexts. |\n| `PUBLIC_KEY_LEN` `SIGNATURE_LEN` `KEM_*` `SHARED_SECRET_LEN` `OPRF_*` | Fixed sizes. |\n\nThe full register/login orchestration (the four binary endpoints, the\nanti-enumeration decoy, the atomic challenge-consume) is in\n`examples/basic/server/routes/Auth.ts`. **Storage is the app's** - a tenant's\nwasm memory is wiped per request, so accounts and challenges live in an external\nstore, and challenge-consume **must** be an atomic fetch-and-delete (a\nread-then-delete race makes a captured login replayable). The example uses a\n**dev-only** KV for this; production wires toildb (see `docs/auth-todo.md`).\n\n### Sessions\n\n| Member | Signature | Notes |\n| --- | --- | --- |\n| `getUser()` | `(): AuthUser \\| null` | The signed-in user, decoded from the verified session, auto-typed to your `@user`. |\n| `hasSession()` | `(): bool` | Whether the request carries a valid, unexpired session. What `@auth` calls. |\n| `mintSession(userData, ttlSecs?)` | `(Uint8Array, u64=86400): Cookie` | Signed `__Host-toil_sess` cookie carrying `user.encode()`. HttpOnly, Secure, SameSite=Lax. |\n| `clearSession()` / `userCookie(...)` / `clearUserCookie()` | | Logout; the readable `__Secure-toil_user` companion (display-only); clear it. |\n\nThe session payload is `u8 version || u64 iat || u64 exp || bytes userData`, sealed\nwith HMAC-SHA256. The HttpOnly `__Host-toil_sess` is the **only** cookie the\nserver trusts; the readable `__Secure-toil_user` exists solely so the client\n`getUser()` can show a name without a round-trip and must never gate anything.\n\n## The client half\n\n```ts\nimport { Auth } from 'toiljs/client';\n\nawait Auth.register(username, password); // OPRF + Argon2id + ML-DSA keypair, send only the public key + PoP\nawait Auth.login(username, password); // + ML-KEM encapsulate; resolves only if the server's confirm tag verifies\n```\n\n`login` resolves **only after** the client verifies the server's confirmation tag\n- so a resolved `login` means mutual authentication succeeded. The secret key,\nseed, and shared secret are zeroized as soon as they are used. There is no\nrecovery: the password *is* the key (see `docs/auth-todo.md` for the recovery\nwork).\n\nThe generated `shared/server.ts` also exports a typed, no-argument client\n`getUser()` that reads the readable companion cookie. It is **display-only and\nuntrusted** - a client can forge it, fooling only its own UI. The server\nre-verifies the signed session on every `@auth` request, so authorization never\ndepends on the readable cookie.\n\n## Security checklist\n\n- Set real secrets in `main.ts`: `setSecret`, `setOprfSeed`, and the server KEM\n keypair - per-deployment, identical on every instance, never in a client\n bundle. The defaults are insecure DEV placeholders.\n- Pin **your** server KEM public key in the client and rotate it; the example\n ships a throwaway dev keypair.\n- Use a production Argon2id cost (≥ 256 MiB, ≥ 3 iterations); the demo is tuned\n for browser responsiveness.\n- Back accounts/challenges with a shared store and make challenge-consume atomic.\n- Rate-limit `register` and `login` (online guessing is not stopped by the\n offline-attack resistance).\n- Always verify server-side. The server `getUser()` decodes a verified,\n expiry-checked session; the client `getUser()` does not and must not gate\n anything.\n- This is an unreviewed hybrid composition - get a cryptographic review before it\n backs real credentials. Tracked in [`docs/auth-todo.md`](./auth-todo.md).\n",
20
+ "environment.md": "# Environment variables & secrets\n\n`Environment` gives a tenant **per-app environment variables and secrets**, set\nout of band (a dashboard, like GitHub Actions) so the deployed `.wasm` carries\n**no credentials**. It is read-only from app code, there is no `set`; values are\nconfigured on the deployment side, never from the module.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('cfg')\nclass Cfg {\n @get('/')\n show(ctx: RouteContext): Response {\n const base = Environment.get('PUBLIC_API_BASE'); // plain var, or null\n const key = Environment.getSecure('STRIPE_KEY'); // secret, or null\n // Use `key` to call a third party; never log it or return it to a client.\n return Response.text(base != null ? base : 'unset');\n }\n}\n```\n\n`Environment` is a global, no import needed (like `EmailService` / `AuthService`).\n\n## Two disjoint buckets\n\nJust like GitHub Actions' `vars` vs `secrets`:\n\n- **`Environment.get(key)`** reads **plain vars**, non-sensitive config (a public\n API base URL, a feature flag, a region). Returns the string, or `null`.\n- **`Environment.getSecure(key)`** reads **secrets**, sensitive values (a\n third-party API key). Returns the string, or `null`.\n\nThe buckets are **disjoint**: a secret is **never** returned by `get()`, and a\nplain var is never returned by `getSecure()`. That keeps a secret from leaking\nthrough a code path that logs the result of a `get()`. Keys are case-sensitive,\nexact-match.\n\n> Secrets you read with `getSecure` are plaintext in your module at runtime\n> (that's the point, you need them to call out). Don't log them, don't put them\n> in a response, and don't copy them into a client bundle.\n\n## What is NOT here\n\nFramework-reserved namespaces (today: **email** provider config) are **host-only**,\nresolved and used in Rust where the framework needs them, and **never exposed to\nthe `.wasm`**. There is no `Environment.email`; you configure email in the\n`[email]` block of the same env file and the platform uses it for you (see\n[Email](./email.md)). The env imports only ever see your own `vars` / `secrets`.\n\n## Where values live\n\nVars and secrets live in **two separate dotenv (`.env`) files**, so the disjoint\nsplit is structural and the secrets file can be locked down on its own. On the\nedge they are per host, **out of `hosts/`** (so the config watcher never sees a\ncredential), the dashboard / edge database replaces them later:\n\n```bash\n# $TOIL_ENV_DIR/<host>.env (default dir /run/toil/env)\nPUBLIC_API_BASE=https://api.example.com # -> Environment.get(\"PUBLIC_API_BASE\")\nREGION=eu\n\n# $TOIL_ENV_DIR/<host>.env.secrets (mode 0600)\nSTRIPE_KEY=sk_live_xxx # -> Environment.getSecure(\"STRIPE_KEY\")\n\n# host-only email config, reserved TOIL_EMAIL_* keys, NEVER exposed to the .wasm\nTOIL_EMAIL_ENABLED=true\nTOIL_EMAIL_PROVIDER=resend\nTOIL_EMAIL_FROM=noreply@example.com\nTOIL_EMAIL_API_KEY=re_xxx\n```\n\nEach file is plain dotenv: `KEY=value` per line, `#` comments, optional `export`,\noptional quotes. Keys with the reserved **`TOIL_`** prefix are framework/host-only\nand are stripped from BOTH guest buckets, a tenant can never read them via\n`get`/`getSecure` (see [Email](./email.md) for `TOIL_EMAIL_*`).\n\nOn the edge, env is loaded **lazily** (the first time your code reads it) into a\n**bounded, shared, read-only cache** with idle eviction: the data lives in one\nplace and is never copied per request, a host that never reads env costs nothing,\nsecrets are wiped when a host goes cold, and a flood of requests to many distinct\nhosts can never grow memory without bound.\n\n## In dev\n\n`toiljs dev` reads `.env` (vars) and `.env.secrets` (secrets) at your project\nroot, and overlays `process.env` as plain vars for convenience. Both are\ngitignored by the scaffold. The ABI is identical to the edge, so code that runs\nin dev runs on the edge.\n\n```bash\n# .env (vars)\nPUBLIC_API_BASE=http://localhost:4000\n\n# .env.secrets (secrets; 0600; gitignored)\nSTRIPE_KEY=sk_test_xxx\n```\n",
21
+ "email.md": "# Email\n\ntoiljs can send transactional email from a route handler. A handler calls\n`EmailService.send(...)` (or a typed `EmailTemplate` / `Emails.*` from the\n`emails/` folder, or the stateless `TwoFactor` helper); the edge hands the\nmessage to a single off-core mailer thread that talks to the provider over the\nkernel network (never the worker cores), and **suspends** the wasm call until the\nprovider responds, so a slow send never blocks the worker.\n\nEverything here is an ambient **global** (no import), like `crypto` and\n`AuthService`. A tenant that never sends email pulls none of it into its build.\n\n- **`EmailService`**, send one email.\n- **`EmailTemplate`**, a reusable template with `{{placeholder}}` substitution\n (plain text and/or HTML).\n- **`emails/*.tsx`**, author emails as React components; the build renders them\n to static HTML and generates a typed `Emails.<Name>.send(...)`.\n- **`TwoFactor`**, stateless email verification codes (2FA / confirm), no DB.\n\n> **The one rule of HTML email:** email clients run **no JavaScript** and strip\n> `<style>`/external CSS. So HTML email is a static, inline-styled string, and\n> any \"rendering\" (React, CSS files) happens at **build time**, not at send time.\n> See [React email templates](#react-email-templates).\n\n## Configure email\n\nEmail is a **framework-reserved namespace of the tenant's environment**, the\nsame out-of-band [Environment](./environment.md) store that backs\n`Environment.get` / `getSecure`, but the `[email]` block is **host-only**: it is\nread and used in Rust (the off-core mailer) and is **never exposed to the\n`.wasm`**. The provider key never lives in the deployed module or in the\ninotify-watched `hosts/<host>.toml`.\n\nOn the edge today it lives in the tenant's env secrets file,\n`$TOIL_ENV_DIR/<host>.env.secrets` (default dir `/run/toil/env`), kept `0600` and\n**out of `hosts/`** so the config watcher never sees a credential (the dashboard /\nedge DB replaces this file later). Email config is a set of **reserved\n`TOIL_EMAIL_*` keys**, host-only, stripped from the guest buckets, so a tenant\ncan never read them via `Environment.getSecure`:\n\n```bash\n# $TOIL_ENV_DIR/<host>.env.secrets (mode 0600; NOT under hosts/, NOT in the .wasm)\n\nTOIL_EMAIL_ENABLED=true\nTOIL_EMAIL_FROM=you@example.com # validated; single address, no CRLF\nTOIL_EMAIL_PROVIDER=resend # resend | gmail | smtp\nTOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx # the provider credential\nTOIL_EMAIL_MAX_PER_MIN=60 # per-host send budget: rolling 1-minute cap\nTOIL_EMAIL_MAX_PER_DAY=0 # per-host send budget: rolling 24-hour cap (0 = unlimited)\nTOIL_EMAIL_MAX_PER_RECIPIENT_PER_HOUR=5 # anti-abuse cap per recipient\n```\n\nThe same file also carries the tenant's own secrets (and `<host>.env` their plain\nvars; see [Environment](./environment.md)); the `TOIL_EMAIL_*` keys are just the\nreserved namespace the framework consumes.\n\nWhen `enabled` is `false` (the default) the host has no email capability and\n`EmailService.send` returns `Disabled`. The env is loaded **lazily** (on the\nfirst send) and the `api_key` is materialized into a zeroizing secret in host\nmemory, never written to logs or `/_admin`. A malformed `[email]` block is\ntreated as \"no email\" (the host returns `Disabled`); validate config on the\ndashboard before deploying.\n\n### Providers\n\n**Resend** (`provider = \"resend\"`), a JSON API; `api_key` holds the API key.\n\n**Gmail** (`TOIL_EMAIL_PROVIDER=gmail`), SMTP with Gmail defaults:\n`smtp.gmail.com`, port `587` (STARTTLS), username = `from`. `TOIL_EMAIL_API_KEY`\nholds a Gmail **App Password** (create one at\n<https://myaccount.google.com/apppasswords>; the account needs 2-Step\nVerification). No extra keys needed:\n\n```bash\nTOIL_EMAIL_ENABLED=true\nTOIL_EMAIL_FROM=you@gmail.com\nTOIL_EMAIL_PROVIDER=gmail\nTOIL_EMAIL_API_KEY=abcd efgh ijkl mnop\n```\n\n**Generic SMTP** (`TOIL_EMAIL_PROVIDER=smtp`), any submission server (Outlook,\nSendGrid/Mailgun SMTP, your own). Requires `TOIL_EMAIL_SMTP_HOST`; port defaults\nto `587` (STARTTLS), or set `465` for implicit TLS. `TOIL_EMAIL_SMTP_USER`\ndefaults to `from`.\n\n```bash\nTOIL_EMAIL_ENABLED=true\nTOIL_EMAIL_FROM=noreply@example.com\nTOIL_EMAIL_PROVIDER=smtp\nTOIL_EMAIL_API_KEY=the-smtp-password\nTOIL_EMAIL_SMTP_HOST=smtp.example.com\nTOIL_EMAIL_SMTP_PORT=587\nTOIL_EMAIL_SMTP_USER=noreply@example.com\n```\n\n### In dev\n\n`toiljs dev` runs the **full email pipeline** in Node, recipient validation,\ndedup, and the per-minute / per-day / per-recipient caps all behave exactly like\nthe edge, and **really sends** once you configure a provider. Configure it in\n`toil.config.ts` (non-secret) with the API key in `.env.secrets`:\n\n```ts\n// toil.config.ts\nimport { defineConfig } from 'toiljs/compiler';\nexport default defineConfig({\n server: {\n email: { provider: 'resend', from: 'you@example.com', maxPerMin: 60 },\n },\n});\n```\n\n```bash\n# .env.secrets (gitignored)\nTOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx\n```\n\n`TOIL_EMAIL_*` env vars override the config file (so the same `.env.secrets` the\nedge uses works in dev too). Supports `resend` and `gmail`/`smtp` (SMTP via\nnodemailer). **Not configured?** `EmailService.send` stays a log-only mock and\nreturns `Sent`, so a flow that sends email still works without setup.\n\n> Because the dev server runs the guest **synchronously**, the actual network\n> send is fire-and-forget: validation + caps return their exact status\n> immediately, but a `Sent` is optimistic and the real delivery outcome (or\n> `ProviderError`) is logged, not returned. The ABI is identical to the edge, so\n> code that runs in dev runs on the edge.\n\n## Sending email\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('notify')\nclass Notify {\n @post('/welcome')\n welcome(ctx: RouteContext): Response {\n const status = EmailService.send(\n 'alice@example.com',\n 'Welcome!',\n 'Thanks for signing up.', // plain-text body\n 'welcome', // purpose tag (dedup / abuse keying)\n '<h1>Thanks for signing up.</h1>', // optional HTML body\n );\n return status == EmailStatus.Sent\n ? Response.text('sent\\n')\n : Response.text('could not send\\n', 503);\n }\n}\n```\n\n`send(to, subject, body, purpose = 'tx', html = '')` returns an **`EmailStatus`**:\n\n| Status | Meaning | Retry? |\n| ----------------- | ----------------------------------------------------------- | ---------------- |\n| `Sent` | Accepted by the provider |, |\n| `Deduped` | An identical recent `(recipient, purpose)` was collapsed | treat as sent |\n| `Budget` | The host's per-minute budget is exhausted | yes, later |\n| `TryLater` | The mailer was saturated / a queue was full | yes, back off |\n| `RecipientCapped` | The per-recipient hourly cap was hit | no (this window) |\n| `BadRecipient` | The address failed validation (CRLF, multiple addresses) | no |\n| `Disabled` | This host has no `[email]` capability | no |\n| `ProviderError` | The provider rejected it, or transport failed after retries | no |\n\n`purpose` is a short, non-PII tag (`\"welcome\"`, `\"reset\"`, …). The mailer folds\nit into the **dedup** key (identical `(host, recipient, purpose)` within ~30s is\ncollapsed to one send) and the abuse counters. It is never logged in the clear.\n\nThe recipient is validated host-side (exactly one address, no CR/LF/`<>`), so a\nguest can never smuggle a second envelope recipient or a header into the send.\n\n## Templates\n\n`EmailTemplate` is a reusable message with `{{placeholder}}` substitution, for\nwhen the same email is sent with different values:\n\n```ts\nconst welcome = new EmailTemplate(\n 'Welcome, {{name}}!', // subject\n 'Hi {{name}}, your code is {{code}}.', // plain-text body\n '<h1>Welcome, {{name}}</h1><p>Code: <b>{{code}}</b></p>', // html (optional)\n);\n\nconst vars = new Map<string, string>();\nvars.set('name', 'Alice');\nvars.set('code', '123456');\nconst status = welcome.send('alice@example.com', vars, 'welcome');\n```\n\n- `{{ name }}` (with surrounding spaces) is accepted; an unknown placeholder\n renders to the empty string.\n- Omit the `html` argument for a plain-text template.\n- `template.render(vars)` returns the rendered `{ subject, body, html }` without\n sending (useful for preview/testing).\n\nFor anything richer than `{{token}}` substitution, real layout, CSS, brand,\nauthor the email as a React component instead.\n\n## React email templates\n\nWrite emails as React components in an **`emails/`** folder. At `toiljs build`\neach one is rendered **once, at build time**, to static inline-CSS HTML (because\nthe inbox runs no JS), with the component's props turned into `{{token}}` holes;\nthe build then generates a typed `Emails.<Name>.send(...)` your server calls.\n\n```tsx\n// emails/Welcome.tsx\nexport const subject = 'Welcome, {{name}}!';\n\nexport default function Welcome({ name, code }: { name: string; code: string }) {\n return (\n <table\n width=\"100%\"\n style={{ fontFamily: 'Arial, sans-serif' }}>\n <tbody>\n <tr>\n <td style={{ padding: '24px' }}>\n <h1 style={{ color: '#111' }}>Welcome, {name}!</h1>\n <p>\n Your code is <b>{code}</b>.\n </p>\n </td>\n </tr>\n </tbody>\n </table>\n );\n}\n```\n\nThe generated `Emails.Welcome.send(...)` takes the recipient, then one argument\nper `{{token}}` **in alphabetical order**, then an optional `purpose`:\n\n```ts\n// emails/Welcome.tsx uses {{code}} and {{name}} -> params are (code, name)\nconst status = Emails.Welcome.send('alice@example.com', '123456', 'Alice');\n```\n\nAuthoring notes:\n\n- **Styles must end up inline.** Email clients strip `<style>`/external CSS, so\n write inline `style={{ ... }}`, or import a stylesheet and its rules are\n inlined into element `style=\"…\"` for you at build (a bare CSS import has no\n effect on its own under SSR). Keep email-only styles next to the email, e.g.\n `import './styles/email.css'`, or **reuse existing project CSS** with `import\n'client/styles/…'` (the `client/*` alias points at your client source).\n- **Optional exports:** `export const subject` (a token template; defaults to the\n email name), `export const text` (a plain-text alternative; otherwise derived\n from the HTML), `export const purpose`.\n- **Build-time, field substitution only.** Because the component renders once at\n build, per-send data is `{{token}}` substitution, a runtime `{items.map(...)}`\n or conditional bakes in at build, it does not re-run per recipient. That covers\n transactional / 2FA / confirmation email; dynamic lists need a different\n approach.\n- The generated `server/_emails.ts` is regenerated on `build`/`dev` and should be\n gitignored.\n\n### Preview while you author\n\nWhile `toiljs dev` runs, open **`/__toil/emails`** (the dev banner prints the\nlink). It lists every `emails/*.tsx`, renders the selected one exactly as the\nbuild does (imported `client/*` CSS inlined), lets you fill each `{{token}}` to\nsee the result, toggle the HTML and plain-text parts, and open the file in your\neditor. It refreshes live as you edit the template or its CSS.\n\n## Email verification codes (`TwoFactor`)\n\n`TwoFactor` is a **stateless** email-code primitive (2FA, email confirmation,\nmagic codes), no database. It emails a random code and returns a signed\n**token** that commits to the code via HMAC, without putting the code in the\ntoken (the code is only in the email). Verification recomputes the HMAC from the\ntoken plus the user-entered code, so a valid `(token, code)` pair can only come\nfrom someone who both received the email and holds the token.\n\n```ts\n// 1. Issue + email a code; hand `token` to the client (a cookie or hidden field).\nconst challenge = TwoFactor.send('alice@example.com', 'login'); // emails the code\n// challenge.token -> give to the client\n// challenge.status -> the EmailStatus of the send\n\n// 2. Later, verify what the user typed.\nconst ok: bool = TwoFactor.verify(challenge.token, 'alice@example.com', userEntered);\n```\n\n- **`send(recipient, purpose, ttlSecs = 600, digits = 6)`**, issues a code,\n emails it with a built-in template, returns `{ token, status }`.\n- **`issue(recipient, purpose, ttlSecs, digits)`**, returns `{ code, token }`\n **without** sending, so you can email `code` with your own `EmailTemplate` /\n `Emails.*` for a branded message.\n- **`verify(token, recipient, code)`**, `true` only for a code issued for that\n recipient that hasn't expired. Constant-time compare.\n- **`TwoFactor.setSecret(secret)`**, the HMAC secret for the tokens. Call once\n at startup in `main.ts`; it must be identical on every edge instance and out of\n any client bundle. (This is separate from the provider `api_key`.)\n\n**Limitation:** this gives integrity + expiry but **not single-use**, a valid\ncode verifies repeatedly within its TTL, because there is no server state to burn\nit. Keep the TTL short; for true single-use, store a per-recipient\nlast-verified-at and reject at or before it.\n\n## Limits and abuse controls\n\nAll enforced authoritatively in the single mailer (so the counts are exact across\nall workers):\n\n- **Per-host budget**, two rolling windows, both enforced: a 1-minute cap\n (`max_per_min`) and a 24-hour cap (`max_per_day`, `0` = unlimited). Over either\n one → `Budget`. Each host's caps, in-window sends, and reject counts are visible\n per host at `GET /_admin/email`.\n- **Per-recipient cap**, `max_per_recipient_per_hour`. Over it →\n `RecipientCapped`.\n- **Dedup**, identical `(host, recipient, purpose)` within ~30s → `Deduped`.\n\nEditing these in the host config takes effect on the next send (no restart).\n\n## Observability\n\n`GET /_admin/email` returns process-wide counters by reason (JSON), e.g.\n`submitted`, `sent`, `deduped`, `budget`, `recipient_capped`, `try_later`,\n`bad_recipient`, `provider_error`. It exposes **counts only**, never a\nrecipient, code, subject, body, or secret.\n\n## See also\n\n- [Rate limiting](./ratelimit.md), protect your routes (including any email\n trigger) with `@ratelimit`.\n- [Web Crypto](./crypto.md), the `crypto` global `TwoFactor` builds on.\n",
22
+ "cookies.md": "# Cookies\n\nA complete HTTP cookie layer for the toiljs server runtime, covering the full\nRFC 6265bis surface (including `SameSite`, the `Partitioned`/CHIPS attribute, and\nthe `__Host-` / `__Secure-` prefixes) plus cryptographic signing and encryption.\n\n`Cookie`, `Cookies`, `CookieMap`, `SecureCookies`, and the `SameSite` /\n`CookieEncoding` / `CookiePrefix` enums are **ambient globals**: a handler uses\nthem with **no import**, exactly like `crypto`. They are also exported from\n`toiljs/server/runtime` for anyone who prefers an explicit import.\n\n- [How \"global, no import\" works](#how-global-no-import-works)\n- [Quick start](#quick-start)\n- [The `Cookie` builder](#the-cookie-builder)\n- [The `Cookies` parser and codec](#the-cookies-parser-and-codec)\n- [`CookieMap`](#cookiemap)\n- [`SecureCookies` signing and encryption](#securecookies-signing-and-encryption)\n- [`Request` and `Response` integration](#request-and-response-integration)\n- [`base64url` helpers](#base64url-helpers)\n- [Encoding vs encryption](#encoding-vs-encryption)\n- [Security notes](#security-notes)\n- [Spec compliance](#spec-compliance)\n- [Testing](#testing)\n- [API reference](#api-reference)\n\n---\n\n## How \"global, no import\" works\n\nThe cookie types are declared with ToilScript's `@global` decorator and pulled\ninto every server build (re-exported from `toiljs/server/runtime` and\nside-effect-imported by `toiljs/server/runtime/exports`, which every `main.ts`\nre-exports). At compile time the symbols register in the global scope, so a\nhandler can write `Cookie.create(...)` or `req.cookie(...)` without importing\nanything.\n\nFor the editor, `toiljs create` scaffolds `server/toil-server-env.d.ts` with\nambient `declare`s for these globals (the toilscript compiler ignores `.d.ts`;\nit only feeds the language service). If you would rather import them:\n\n```ts\nimport { Cookie, Cookies, SecureCookies, SameSite } from 'toiljs/server/runtime';\n```\n\n---\n\n## Quick start\n\n```ts\nimport { ToilHandler, Request, Response } from 'toiljs/server/runtime';\n\nexport class AppHandler extends ToilHandler {\n public handle(req: Request): Response {\n // Read (no import needed for Cookie / Cookies / SameSite, they are global).\n const sid = req.cookie('sid'); // string | null\n\n // Write a hardened session cookie.\n return Response.json('{\"ok\":true}').setCookie(\n Cookie.create('sid', 'abc123')\n .httpOnly()\n .secure()\n .sameSite(SameSite.Lax)\n .maxAge(3600)\n .asHostPrefixed(), // forces Secure + Path=/ + no Domain\n );\n }\n}\n```\n\n---\n\n## The `Cookie` builder\n\nA fluent builder that serializes to one `Set-Cookie` field value. Every setter\nreturns the cookie, so calls chain.\n\n```ts\nconst c = Cookie.create('id', 'abc123')\n .domain('example.com')\n .path('/app')\n .maxAge(3600)\n .secure()\n .httpOnly()\n .sameSite(SameSite.Lax);\n\nc.serialize();\n// \"id=abc123; Domain=example.com; Path=/app; Max-Age=3600; SameSite=Lax; Secure; HttpOnly\"\n```\n\n### Fields\n\n| Field | Type | Notes |\n| --- | --- | --- |\n| `name` | `string` | The cookie name (a token; never encoded). |\n| `value` | `string` | The logical value (encoded per `encoding` on serialize). |\n| `encoding` | `CookieEncoding` | Wire encoding for the value. Default `Percent`. |\n\n### Construction\n\n- `new Cookie(name, value)`\n- `Cookie.create(name, value): Cookie`, a builder-style alias.\n\n### Attribute setters\n\n| Method | Attribute |\n| --- | --- |\n| `domain(v: string)` | `Domain` |\n| `path(v: string)` | `Path` (must begin with `/`) |\n| `maxAge(seconds: i64)` | `Max-Age` (`0` / negative expire immediately) |\n| `expires(epochSeconds: i64)` | `Expires`, formatted as an IMF-fixdate (`Sun, 06 Nov 1994 08:49:37 GMT`) |\n| `expiresRaw(date: string)` | `Expires` verbatim (escape hatch) |\n| `secure(on: bool = true)` | `Secure` |\n| `httpOnly(on: bool = true)` | `HttpOnly` |\n| `sameSite(s: SameSite)` | `SameSite` |\n| `partitioned(on: bool = true)` | `Partitioned` (CHIPS) |\n| `priority(p: string)` | `Priority` (`Low` / `Medium` / `High`) |\n| `extension(av: string)` | An arbitrary extension attribute, appended verbatim |\n| `withEncoding(e: CookieEncoding)` | Choose the value wire encoding |\n\n### Prefixes\n\n- `asSecurePrefixed(): Cookie`, prepends `__Secure-` and forces `Secure`.\n- `asHostPrefixed(): Cookie`, prepends `__Host-` and forces `Secure`, `Path=/`, and no `Domain`.\n- `detectedPrefix(): CookiePrefix`, the prefix detected on the current name (case-insensitive).\n\n### Output\n\n- `serialize(strict: bool = false): string`, returns the `Set-Cookie` value. Lenient by\n default (always returns a best-effort cookie); pass `strict = true` to throw on\n a hard validation failure. `Secure` is added automatically when `SameSite=None`\n or `Partitioned` is set; `Max-Age` is clamped to the 400-day cap; control\n characters are stripped from the name, value, and attributes.\n- `toString(): string`, alias for `serialize()`.\n- `encodedValue(): string`, the value transformed per `encoding`.\n\n### Validation\n\n`validate(): CookieValidation` checks the cookie against RFC 6265bis and returns\na structured result:\n\n```ts\nclass CookieValidation {\n valid: bool;\n errors: Array<string>;\n}\n```\n\nIt flags: a non-token name, name+value over 4096 bytes, a `Domain`/`Path` over\n1024 bytes, a `Path` not starting with `/`, a `Raw` value outside `cookie-octet`,\nthe `__Host-` / `__Secure-` prefix requirements, `SameSite=None` or `Partitioned`\nwithout `Secure`, and a `Max-Age` beyond the 400-day cap.\n\n### Attribute serialization order\n\n`name=value` then, when set: `Domain`, `Path`, `Expires`, `Max-Age`, `SameSite`,\n`Secure`, `HttpOnly`, `Partitioned`, `Priority`, then any `extension(...)` values.\n(Attribute order is not significant to user agents; the order is stable so output\nis predictable.)\n\n### Enums\n\n```ts\nenum SameSite { Default, None, Lax, Strict } // Default omits the attribute\nenum CookieEncoding { Percent, Raw, Base64Url } // value wire encoding\nenum CookiePrefix { None, Secure, Host }\n```\n\n---\n\n## The `Cookies` parser and codec\n\nStatic helpers for the read side and a one-shot serializer.\n\n| Method | Description |\n| --- | --- |\n| `Cookies.parse(cookieHeader: string): CookieMap` | Parse a request `Cookie` header (`a=1; b=2`). Values are percent-decoded; one layer of surrounding quotes is stripped; malformed pairs and empty names are skipped. On a duplicate name the first wins. |\n| `Cookies.get(cookieHeader: string, name: string): string \\| null` | Parse and return one value. |\n| `Cookies.serialize(name: string, value: string): string` | One-shot `name=value` with no attributes (percent-encoded). For attributes, build a `Cookie`. |\n| `Cookies.parseSetCookie(setCookie: string): Cookie` | Parse a `Set-Cookie` line back into a `Cookie` (for clients, tests, proxies). Kept verbatim (`CookieEncoding.Raw`) so re-serializing reproduces the wire form. |\n| `Cookies.encodeValue(raw: string): string` | Percent-encode a value (the default `Cookie` encoding). |\n| `Cookies.decodeValue(enc: string): string` | Percent-decode a value (the inverse). |\n\n```ts\nconst jar = Cookies.parse('sid=abc123; theme=dark');\njar.get('sid'); // \"abc123\"\n\nCookies.serialize('sid', 'a b'); // \"sid=a%20b\"\n```\n\n---\n\n## `CookieMap`\n\nThe ordered name to value view returned by `Cookies.parse` and `Request.cookies()`.\nBacked by parallel arrays (a request carries a handful of cookies, so a linear\nscan beats hashing and keeps the runtime small).\n\n| Member | Description |\n| --- | --- |\n| `get(name: string): string \\| null` | The value, or `null`. |\n| `has(name: string): bool` | Whether the cookie is present. |\n| `names(): Array<string>` | A copy of the names, in encounter order. |\n| `size: i32` | The number of cookies. |\n| `set(name: string, value: string): void` | Insert unless present (keep-first). Used by `parse`; rarely called directly. |\n\n---\n\n## `SecureCookies` signing and encryption\n\nTamper-proof and confidential cookie values, built on the `crypto` global (no new\nhost functions).\n\n- **`SecureCookies.signed(key)`**: HMAC-SHA256. The value stays readable but is\n bound to the cookie name, so it cannot be tampered with or moved to another\n cookie. Sealed form: `base64url(value) \".\" base64url(mac)`.\n- **`SecureCookies.encrypted(key)`**: AES-256-GCM (or AES-128-GCM) with a random\n 96-bit IV and the cookie name as additional authenticated data. The value is\n confidential and authenticated. Sealed form: `base64url(iv ‖ ciphertext ‖ tag)`.\n\nKeys are caller-supplied raw bytes:\n\n- HMAC: any length (32+ bytes recommended).\n- AES: exactly 16 or 32 bytes (enforced up front; a wrong length is rejected by\n the factory).\n\n```ts\n// A real app loads a long random secret from config; never hard-code one.\nconst key = Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));\n\n// Signed (readable, 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 (confidential + 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\n| Method | Description |\n| --- | --- |\n| `SecureCookies.signed(key: Uint8Array)` | HMAC-SHA256 signer/verifier. |\n| `SecureCookies.encrypted(key: Uint8Array)` | AES-GCM (16- or 32-byte key). |\n| `addKey(key: Uint8Array): SecureCookies` | Add a fallback key for rotation: seal with the first, open with any. |\n| `sign(name, value): string` | Sealed signed value. |\n| `unsign(name, sealed): string \\| null` | Verify and recover, or `null`. |\n| `encrypt(name, value): string` | Sealed encrypted value. |\n| `decrypt(name, sealed): string \\| null` | Decrypt, or `null`. |\n| `seal(cookie: Cookie): Cookie` | Seal a cookie's value in place (sign or encrypt per the instance mode) and mark it `Raw`. Returns the same cookie. |\n| `open(jar: CookieMap, name): string \\| null` | Read and open cookie `name` from a parsed jar. |\n\n**Key rotation:** seal with `keys[0]`; `unsign` / `decrypt` try every key in turn,\nso you can add a new key as the first and keep an old one as a fallback while\nexisting cookies age out.\n\n```ts\nconst signer = SecureCookies.signed(newKey).addKey(oldKey);\n```\n\n---\n\n## `Request` and `Response` integration\n\nBecause every handler already has a `Request` and returns a `Response`, the most\ncommon operations live there directly.\n\n**Read (`Request`):**\n\n| Method | Description |\n| --- | --- |\n| `req.cookies(): CookieMap` | All cookies, parsed from the `Cookie` header (cached for the request). |\n| `req.cookie(name: string): string \\| null` | One cookie value. |\n\n**Write (`Response`, builder-style):**\n\n| Method | Description |\n| --- | --- |\n| `resp.setCookie(cookie: Cookie): Response` | Append a `Set-Cookie`. Each call adds its own header (cookies are never folded). |\n| `resp.setCookieKV(name, value): Response` | Shorthand for `setCookie(new Cookie(name, value))`. |\n| `resp.clearCookie(name, path = '/', domain = ''): Response` | Append a deletion cookie (empty value, `Max-Age=0`, epoch `Expires`). `path` / `domain` must match the original. |\n\n---\n\n## `base64url` helpers\n\nUnpadded base64url (RFC 4648 §5), used internally by `SecureCookies` and exported\nfor convenience. Its alphabet (`A-Z a-z 0-9 - _`) is within the `cookie-octet`\ngrammar and invariant under percent-encoding, so encoded values round-trip\ncleanly through the default cookie codec.\n\n| Function | Description |\n| --- | --- |\n| `base64UrlEncode(data: Uint8Array): string` | Encode bytes as unpadded base64url. |\n| `base64UrlDecode(s: string): Uint8Array \\| null` | Decode base64url/base64 (padding and whitespace tolerated); `null` on an invalid character or length. |\n\n---\n\n## Encoding vs encryption\n\nTwo independent layers, easy to mix up:\n\n- **Encoding** (`CookieEncoding`) is transport-only and reversible by anyone. It\n keeps an arbitrary value inside the `cookie-octet` grammar.\n - `Percent` (default): `encodeURIComponent`-style; arbitrary UTF-8 is safe.\n - `Base64Url`: UTF-8 then base64url.\n - `Raw`: no transformation (the value must already be valid `cookie-octet`).\n- **Signing / encryption** (`SecureCookies`) is cryptographic. Signing keeps the\n value readable but tamper-proof; encryption makes it unreadable and\n authenticated. Both require a secret key.\n\n`SecureCookies.seal` sets the value to its sealed (base64url) form and marks the\ncookie `Raw`, so it passes through the default parse path untouched.\n\n---\n\n## Security notes\n\n- **Panic-free verification.** `unsign` and `decrypt` return `null` on a tampered,\n truncated, or wrong-key value, never a trap. (`decrypt` reads the host return\n code directly instead of letting the underlying crypto throw, because the\n server runs with exceptions disabled.) This makes them safe to call on\n attacker-controlled input.\n- **Name-binding.** Signing MACs `name + \"=\" + value`; encryption uses the name as\n AAD. A sealed value made for one cookie name will not verify or decrypt under\n another.\n- **Control characters are stripped** from the name, value, and attribute values\n on serialize, as a defense-in-depth guard against header injection (CR/LF).\n Control characters are invalid in all of these per the grammar, so nothing\n legitimate is lost. The default value encoding already neutralizes CR/LF.\n- **Prefixes.** `asHostPrefixed()` / `asSecurePrefixed()` apply and enforce the\n browser-recognized guarantees; `validate()` reports a name that carries a prefix\n without satisfying its requirements.\n- **`SameSite=None` and `Partitioned` imply `Secure`** and are emitted with it\n automatically.\n- **Lifetime is clamped** to the RFC 400-day cap on serialize; sizes are checked by\n `validate()`.\n- **Local development.** Browsers treat `http://localhost` as a secure context, so\n `Secure` and `__Host-` cookies work under `toiljs dev` over plain HTTP.\n\nWhen putting untrusted input into a cookie **name** or **attribute** (rather than\nthe value, which is encoded by default), check `validate()` or use\n`serialize(true)`.\n\n---\n\n## Spec compliance\n\nImplements RFC 6265bis (HTTP State Management) and the `Partitioned` (CHIPS)\ncompanion: the `cookie-name` token and `cookie-value` `cookie-octet` grammars,\nthe `Expires` / `Max-Age` / `Domain` / `Path` / `Secure` / `HttpOnly` /\n`SameSite` / `Partitioned` attributes plus `Priority` and arbitrary extensions,\nthe `__Host-` / `__Secure-` prefixes (matched case-insensitively), the 4096-byte\nname+value and 1024-byte attribute limits, the 400-day lifetime cap, the\n`SameSite=None` ⇒ `Secure` rule, and the requirement that each cookie occupy its\nown `Set-Cookie` header (never folded).\n\n---\n\n## Testing\n\n- Pure cookie logic (builder, parser, codec, validation, `Request` / `Response`\n integration) is unit-tested with as-pect in `test/assembly/cookie.spec.ts`\n (`npm run test:server`).\n- `SecureCookies` is exercised end-to-end against the real toilscript-compiled\n wasm with the Node-backed crypto host in `test/devserver.test.ts`\n (`npm test`). It is tested there rather than under as-pect because the as-pect\n compiler does not ship the toilscript crypto standard library.\n\nA live demo (every attribute's serialized output, set/inspect/clear, and an\ninteractive sign/encrypt) is in the example app: run `toiljs dev` in\n`examples/basic` and open `/cookies`. The backend lives in\n`examples/basic/server/core/AppHandler.ts`.\n\n---\n\n## API reference\n\n```ts\n// Globals (also exported from 'toiljs/server/runtime')\n\nenum SameSite { Default, None, Lax, Strict }\nenum CookieEncoding { Percent, Raw, Base64Url }\nenum CookiePrefix { None, Secure, Host }\n\nclass CookieValidation {\n valid: bool;\n errors: Array<string>;\n}\n\nclass Cookie {\n name: string;\n value: string;\n encoding: CookieEncoding;\n static create(name: string, value: string): Cookie;\n domain(v: string): Cookie;\n path(v: string): Cookie;\n maxAge(seconds: i64): Cookie;\n expires(epochSeconds: i64): Cookie;\n expiresRaw(date: string): Cookie;\n secure(on?: bool): Cookie;\n httpOnly(on?: bool): Cookie;\n sameSite(s: SameSite): Cookie;\n partitioned(on?: bool): Cookie;\n priority(p: string): Cookie;\n extension(av: string): Cookie;\n withEncoding(e: CookieEncoding): Cookie;\n asSecurePrefixed(): Cookie;\n asHostPrefixed(): Cookie;\n detectedPrefix(): CookiePrefix;\n encodedValue(): string;\n validate(): CookieValidation;\n serialize(strict?: bool): string;\n toString(): string;\n}\n\nclass CookieMap {\n get(name: string): string | null;\n has(name: string): bool;\n names(): Array<string>;\n size: i32;\n set(name: string, value: string): void;\n}\n\nclass Cookies {\n static parse(cookieHeader: string): CookieMap;\n static get(cookieHeader: string, name: string): string | null;\n static serialize(name: string, value: string): string;\n static parseSetCookie(setCookie: string): Cookie;\n static encodeValue(raw: string): string;\n static decodeValue(enc: string): string;\n}\n\nclass SecureCookies {\n static signed(key: Uint8Array): SecureCookies;\n static encrypted(key: Uint8Array): SecureCookies;\n addKey(key: Uint8Array): SecureCookies;\n sign(name: string, value: string): string;\n unsign(name: string, sealed: string): string | null;\n encrypt(name: string, value: string): string;\n decrypt(name: string, sealed: string): string | null;\n seal(cookie: Cookie): Cookie;\n open(jar: CookieMap, name: string): string | null;\n}\n\nfunction base64UrlEncode(data: Uint8Array): string;\nfunction base64UrlDecode(s: string): Uint8Array | null;\n\n// On Request\nreq.cookies(): CookieMap;\nreq.cookie(name: string): string | null;\n\n// On Response (builder-style)\nresp.setCookie(cookie: Cookie): Response;\nresp.setCookieKV(name: string, value: string): Response;\nresp.clearCookie(name: string, path?: string, domain?: string): Response;\n```\n",
23
+ "crypto.md": "# Web Crypto\n\nThe guest gets a synchronous Web Crypto surface through the ambient `crypto`\nglobal, backed by host functions. It mirrors the browser `crypto` /\n`crypto.subtle` API but **without Promises**, ToilScript has no `async`, so\nevery call returns its result directly. Keys are opaque per-request handles in a\nhost keystore; a `CryptoKey` is valid only for the request that created it.\n\n```ts\nconst mac = crypto.hmacSha256(key, message); // Uint8Array\nconst id = crypto.randomUUID(); // string\n```\n\nThis is also what [`SecureCookies`](./cookies.md) and\n[`AuthService`](./auth.md) are built on, so most apps use crypto indirectly.\n\n## `crypto` namespace\n\nConvenience helpers (all synchronous):\n\n| Function | Signature | Notes |\n| --- | --- | --- |\n| `getRandomValues` | `(array: Uint8Array): void` | Fill with CSPRNG bytes. |\n| `randomUUID` | `(): string` | RFC 4122 v4 UUID. |\n| `sha1` / `sha256` / `sha384` / `sha512` | `(data: Uint8Array): Uint8Array` | One-shot digests. |\n| `sha1Text` … `sha512Text` | `(s: string): Uint8Array` | UTF-8 encode then digest. |\n| `hmacSha256` | `(key: Uint8Array, msg: Uint8Array): Uint8Array` | One-shot HMAC-SHA256. |\n| `hmacSha256Text` | `(key: Uint8Array, msg: string): Uint8Array` | HMAC-SHA256 over a UTF-8 string. |\n| `toHex` | `(bytes: Uint8Array): string` | Lowercase hex. |\n| `subtle` | `SubtleCrypto` | The full primitive surface (below). |\n\n## `crypto.subtle`\n\n| Method | Signature |\n| --- | --- |\n| `digest` | `digest(algorithm: string, data: Uint8Array): Uint8Array` |\n| `importKey` | `importKey(format: string, keyData: Uint8Array, algorithm: AlgorithmParams, extractable: bool, usages: i32): CryptoKey` |\n| `exportKey` | `exportKey(format: string, 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\n`digest` takes a named algorithm string (`\"SHA-1\"`, `\"SHA-256\"`, `\"SHA-384\"`,\n`\"SHA-512\"`, `\"SHA3-256\"`, `\"SHA3-384\"`, `\"SHA3-512\"`). `verify` returns a bool\n(it does not throw on a mismatch). Formats are `raw`, `pkcs8`, `spki`; **`jwk`\nis not supported**.\n\n### Algorithm parameter classes\n\n`crypto` and `crypto.subtle` are ambient globals (no import). The params classes\nand the `ALG_*` / `USAGE_*` / `FMT_*` / `CURVE_*` constants and the `CryptoKey`\ntype are imported from the `'crypto'` module:\n\n```ts\nimport { AesGcmParams, HmacImportParams, ALG_SHA_256, USAGE_SIGN } from 'crypto';\n```\n\nEach algorithm has a small params class you pass to `importKey`/`sign`/etc.:\n\n| Class | Constructor |\n| --- | --- |\n| `AesGcmParams` | `(iv, additionalData?, tagLength = 128)` |\n| `AesCbcParams` | `(iv)` |\n| `AesCtrParams` | `(counter, length = 128)` |\n| `HmacImportParams` | `(hash)` |\n| `HmacParams` | `()` |\n| `Pbkdf2Params` | `(hash, salt, iterations)` |\n| `HkdfParams` | `(hash, salt, info?)` |\n| `EcdsaParams` | `(hash)` |\n| `EcKeyImportParams` | `(alg, namedCurve)` |\n| `Ed25519Params` | `()` |\n| `X25519ImportParams` | `()` |\n| `EcdhParams` | `(alg, publicKeyHandle)` |\n\n### Constants\n\n- **Hashes / algorithms:** `ALG_SHA_1`, `ALG_SHA_256`, `ALG_SHA_384`,\n `ALG_SHA_512`, `ALG_SHA3_256/384/512`, `ALG_AES_GCM`, `ALG_AES_CBC`,\n `ALG_AES_CTR`, `ALG_HMAC`, `ALG_ECDSA`, `ALG_ED25519`, `ALG_ECDH`, `ALG_HKDF`,\n `ALG_PBKDF2`.\n- **Key formats:** `FMT_RAW`, `FMT_PKCS8`, `FMT_SPKI` (`FMT_JWK` is rejected).\n- **Usages (bitmask):** `USAGE_ENCRYPT`, `USAGE_DECRYPT`, `USAGE_SIGN`,\n `USAGE_VERIFY`, `USAGE_DERIVE_KEY`, `USAGE_DERIVE_BITS`, `USAGE_WRAP_KEY`,\n `USAGE_UNWRAP_KEY`, OR them together.\n- **Named curves:** `CURVE_P256`, `CURVE_P384` (`CURVE_P521` is not supported).\n\n### `CryptoKey`\n\nAn opaque handle plus metadata: `handle: i32`, `type: string`\n(`secret`/`public`/`private`), `extractable: bool`, `algorithm: i32`,\n`usages: i32`, with `algorithmName()` and `hasUsage(u)`. A key is valid only for\nthe request that imported it.\n\n## Examples\n\nHMAC-SHA256 (one-shot):\n\n```ts\nconst mac = crypto.hmacSha256(key, message);\nconst hex = crypto.toHex(mac);\n```\n\nAES-256-GCM via `subtle`:\n\n```ts\nconst key = new Uint8Array(32); crypto.getRandomValues(key);\nconst iv = new Uint8Array(12); crypto.getRandomValues(iv);\n\nconst k = crypto.subtle.importKey('raw', key, new AesGcmParams(iv, aad, 128), false, USAGE_ENCRYPT);\nconst ct = crypto.subtle.encrypt(new AesGcmParams(iv, aad, 128), k, plaintext);\n```\n\n## Post-quantum verify\n\nThe host also exposes ML-DSA-44 (FIPS 204) signature verification as\n`crypto.mldsa_verify`. It is verify-only, the host never holds a secret key, and\nunderpins the [auth primitive](./auth.md). Most code reaches it through\n`AuthService.verifyLogin(publicKey, message, signature)` rather than calling the\nimport directly. Public key is 1312 bytes, signature 2420 bytes, with a FIPS 204\ndomain-separation context.\n\n## Limitations\n\n- **No Promises**, every call is synchronous.\n- **No RSA** and **no JWK** key format.\n- **P-521** is not supported (P-256 and P-384 are).\n- Signature *generation* for ML-DSA is client-side only; the server verifies.\n",
24
+ "time.md": "# Time\n\n`Time` is the guest's wall-clock. It is the toiljs-blessed way to read the\ncurrent time, backed by the host's `Date.now()` binding (`env.Date.now`). Both\nthe edge and the dev server provide that binding, so time behaves identically in\n`toiljs dev` and in production.\n\nIt is available as an ambient global (`@global`, no import) and is also exported\nfrom `toiljs/server/runtime`.\n\n```ts\nimport { Time } from 'toiljs/server/runtime'; // optional; Time is also a global\n\nconst ms = Time.nowMillis(); // u64 milliseconds since the Unix epoch\nconst s = Time.nowSeconds(); // u64 whole seconds since the Unix epoch\n```\n\n## API\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `Time.nowMillis()` | `static nowMillis(): u64` | Milliseconds since the Unix epoch (the raw host `Date.now()` value). |\n| `Time.nowSeconds()` | `static nowSeconds(): u64` | Whole seconds since the epoch (`nowMillis() / 1000`). The unit used by sessions and login challenges. |\n\n## Semantics\n\n`Time` is **wall-clock, not monotonic**, exactly like browser `Date.now()`. It\ntracks the system clock and can step backward across an NTP correction.\n\n- Use it to stamp and compare absolute instants: session `iat`/`exp`, login\n challenge expiry, cache ages.\n- Do **not** use it to measure elapsed time or as a high-resolution timer; a\n backward step would produce a negative or zero interval.\n\n## Relationship to `Date.now()`\n\nToilScript's `Date.now()` lowers to the same `env.Date.now` host import, so you\n*can* call it directly. Prefer `Time`: it makes the host boundary (and the\nsingle millisecond unit) explicit and easy to find, and it gives you\n`nowSeconds()` without an open-coded `/ 1000` cast at every call site.\n\n`AuthService` uses `Time.nowSeconds()` internally for session `iat`/`exp`, so\nsession timing and any timing you do in a handler share one clock.\n",
25
+ "cli.md": "# CLI\n\n- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,\n `--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.\n- `toiljs dev`, dev server with HMR (`--port`, `--root`). With a `toilconfig.json` it builds\n the server first, then rebuilds it whenever a `server/` file changes (regenerating\n `shared/server.ts`, which Vite HMRs into the client); client-only edits just HMR the client.\n- `toiljs build`, production build. With a `toilconfig.json` it builds the server (toilscript,\n regenerating `shared/server.ts`) first, then the client → `build/client`. `--server` builds\n only the server. Every `server/` file declaring a surface (`@data`/`@rest`/...) is compiled.\n- `toiljs start`, self-host the built app (hyper-express) with a `/_toil` WebSocket channel.\n- `toiljs configure`, toggle styling features on an existing project (see [styling.md](./styling.md)).\n- `toiljs doctor`, diagnose project setup (`--json` for CI). `--fix` auto-wires the typed-RPC\n setup (build scripts, tsconfig `shared` + alias, `.gitignore`, toilscript version, and the\n toilscript prettier plugin) so an existing project upgrades in one command.\n",
26
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Parse a compiled COLD server wasm's `toildaemon.catalog` custom section into the
3
+ * dev daemon scheduler's task list. Emitted by the toilscript cold pass
4
+ * (`buildToilDaemonCatalog`) into `release-cold.wasm` when a `@daemon` class
5
+ * exists. Cron schedules are PRECOMPUTED BITMASKS (RECONCILIATION F6): the reader
6
+ * does bit tests, NEVER a runtime cron-string parse.
7
+ *
8
+ * Byte layout (RECONCILIATION Part 5, all little-endian; mirrors the toilscript
9
+ * `CatWriter` emitter byte-for-byte):
10
+ *
11
+ * u16 format_version = 1
12
+ * u8 has_daemon
13
+ * u16 n_scheduled
14
+ * per task:
15
+ * str name (u32 len + UTF-8)
16
+ * u16 task_index (the scheduled_tick(task_id) argument)
17
+ * u8 schedule_kind (0 = interval, 1 = cron)
18
+ * u64 interval_ms (used when schedule_kind = 0, else 0)
19
+ * u64 cron_minute_mask (bits 0..59)
20
+ * u32 cron_hour_mask (bits 0..23)
21
+ * u32 cron_dom_mask (bits 1..31)
22
+ * u16 cron_month_mask (bits 1..12)
23
+ * u8 cron_dow_mask (bits 0..6)
24
+ * u8 overlap_policy (0 = skip-if-running)
25
+ * u8 catchup_policy (0 = no-backfill)
26
+ * u64 gas_hint
27
+ *
28
+ * Fails closed via `DataReader.ok`: a short read mid-record stops the loop and
29
+ * yields only the cleanly-decoded prefix, never over-reading. An absent or wholly
30
+ * unparseable section returns `null`, so the daemon emulator simply does not start
31
+ * (fail-closed = never run an unknown daemon).
32
+ */
33
+
34
+ import { DataReader } from 'toiljs/io';
35
+
36
+ import { customSection } from '../wasm/sections.js';
37
+
38
+ /** The five precomputed cron field bitmasks (RECONCILIATION Part 5 / F6). */
39
+ export interface CronMasks {
40
+ /** u64, bits 0..59 (one per minute). */
41
+ readonly minute: bigint;
42
+ /** u32, bits 0..23 (one per hour). */
43
+ readonly hour: number;
44
+ /** u32, bits 1..31 (one per day-of-month). */
45
+ readonly dom: number;
46
+ /** u16, bits 1..12 (one per month). */
47
+ readonly month: number;
48
+ /** u8, bits 0..6 (one per day-of-week, 0 = Sunday). */
49
+ readonly dow: number;
50
+ }
51
+
52
+ /** One `@scheduled` task the dev scheduler drives. */
53
+ export interface ScheduledTask {
54
+ readonly name: string;
55
+ /** The `scheduled_tick(task_id)` argument; equals declaration order. */
56
+ readonly taskIndex: number;
57
+ readonly schedule:
58
+ | { readonly kind: 'interval'; readonly ms: number }
59
+ | { readonly kind: 'cron'; readonly masks: CronMasks };
60
+ /** 0 = skip-if-running (the at-most-once default). */
61
+ readonly overlapPolicy: number;
62
+ /** 0 = no-backfill (the at-most-once default). */
63
+ readonly catchupPolicy: number;
64
+ readonly gasHint: bigint;
65
+ }
66
+
67
+ export interface DaemonCatalog {
68
+ readonly hasDaemon: boolean;
69
+ readonly tasks: readonly ScheduledTask[];
70
+ }
71
+
72
+ export function parseDaemonCatalog(wasm: Buffer): DaemonCatalog | null {
73
+ let sec: Buffer | null;
74
+ try {
75
+ sec = customSection(wasm, 'toildaemon.catalog');
76
+ } catch {
77
+ return null; // garbage section table (mid-rebuild) -> no daemon
78
+ }
79
+ if (sec === null) return null;
80
+
81
+ const r = new DataReader(sec);
82
+ r.readU16(); // format_version
83
+ const hasDaemon = r.readU8() === 1;
84
+ const n = r.readU16(); // n_scheduled
85
+ // The 5-byte header must read cleanly; a short read here is a garbage section
86
+ // (fail closed -> no daemon). A `hasDaemon` byte salvaged from a too-short
87
+ // version field is not trustworthy.
88
+ if (!r.ok) return null;
89
+ const tasks: ScheduledTask[] = [];
90
+ for (let i = 0; i < n && r.ok; i++) {
91
+ const name = r.readString();
92
+ const taskIndex = r.readU16();
93
+ const kind = r.readU8(); // schedule_kind: 0 = interval, 1 = cron
94
+ const intervalMs = r.readU64(); // bigint; 0 when kind = 1
95
+ const minute = r.readU64(); // cron_minute_mask (bits 0..59)
96
+ const hour = r.readU32(); // cron_hour_mask
97
+ const dom = r.readU32(); // cron_dom_mask
98
+ const month = r.readU16(); // cron_month_mask
99
+ const dow = r.readU8(); // cron_dow_mask
100
+ const overlapPolicy = r.readU8();
101
+ const catchupPolicy = r.readU8();
102
+ const gasHint = r.readU64();
103
+ if (r.ok)
104
+ tasks.push({
105
+ name,
106
+ taskIndex,
107
+ schedule:
108
+ kind === 1
109
+ ? { kind: 'cron', masks: { minute, hour, dom, month, dow } }
110
+ : { kind: 'interval', ms: Number(intervalMs) },
111
+ overlapPolicy,
112
+ catchupPolicy,
113
+ gasHint,
114
+ });
115
+ }
116
+ // A section that decoded nothing AND claims no daemon is indistinguishable
117
+ // from garbage -> fail closed (no daemon).
118
+ if (!r.ok && tasks.length === 0 && !hasDaemon) return null;
119
+ return { hasDaemon, tasks };
120
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Cron BITMASK evaluation for the dev daemon scheduler. The catalog carries five
3
+ * precomputed bitmasks (RECONCILIATION F6 / Part 5); this module does bit tests
4
+ * against a wall-clock minute, NEVER a cron-string parse. The bit semantics match
5
+ * the toilscript emitter (`expandCronField` in `dbcatalog.ts`):
6
+ *
7
+ * minute bits 0..59 hour bits 0..23 dom bits 1..31
8
+ * month bits 1..12 dow bits 0..6 (0 = Sunday)
9
+ *
10
+ * Standard cron semantics for day-of-month vs day-of-week: when BOTH fields are
11
+ * restricted (not all bits set) the match is the UNION (either matches); when one
12
+ * is unrestricted it does not constrain. The dev evaluator walks forward
13
+ * minute-by-minute from "now" to the next matching minute, exactly as the edge
14
+ * evaluates the same masks.
15
+ */
16
+
17
+ import type { CronMasks } from './catalog.js';
18
+
19
+ /** All 60 minute bits set (bits 0..59). A field the emitter left fully open. */
20
+ const ALL_MINUTES = (1n << 60n) - 1n;
21
+ /** All 24 hour bits set (bits 0..23). */
22
+ const ALL_HOURS = (1 << 24) - 1;
23
+ /** dom bits 1..31 all set (bit 0 unused). `1 << 32` overflows in JS, so spell it. */
24
+ const ALL_DOM = 0xfffffffe;
25
+ /** month bits 1..12 all set (bit 0 unused). */
26
+ const ALL_MONTH = ((1 << 13) - 1) & ~1;
27
+ /** dow bits 0..6 all set. */
28
+ const ALL_DOW = (1 << 7) - 1;
29
+
30
+ function minuteBit(masks: CronMasks, minute: number): boolean {
31
+ return (masks.minute & (1n << BigInt(minute))) !== 0n;
32
+ }
33
+
34
+ /** True when every cron field's bit is set for `date`'s local-time components. */
35
+ export function cronMatches(masks: CronMasks, date: Date): boolean {
36
+ if (!minuteBit(masks, date.getMinutes())) return false;
37
+ if ((masks.hour & (1 << date.getHours())) === 0) return false;
38
+ if ((masks.month & (1 << date.getMonth() + 1)) === 0) return false;
39
+
40
+ // dom/dow union rule (POSIX cron): if both are restricted, either may match.
41
+ const domRestricted = (masks.dom & ALL_DOM) !== ALL_DOM;
42
+ const dowRestricted = (masks.dow & ALL_DOW) !== ALL_DOW;
43
+ const domHit = (masks.dom & (1 << date.getDate())) !== 0;
44
+ const dowHit = (masks.dow & (1 << date.getDay())) !== 0;
45
+ if (domRestricted && dowRestricted) {
46
+ if (!domHit && !dowHit) return false;
47
+ } else if (domRestricted) {
48
+ if (!domHit) return false;
49
+ } else if (dowRestricted) {
50
+ if (!dowHit) return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ /** True when a mask can never fire (all-zero) -> the schedule is rejected. */
56
+ export function cronNeverFires(masks: CronMasks): boolean {
57
+ return (
58
+ (masks.minute & ALL_MINUTES) === 0n ||
59
+ (masks.hour & ALL_HOURS) === 0 ||
60
+ (masks.month & ALL_MONTH) === 0 ||
61
+ // dom OR dow must be able to fire (union); both empty => never.
62
+ ((masks.dom & ALL_DOM) === 0 && (masks.dow & ALL_DOW) === 0)
63
+ );
64
+ }
65
+
66
+ /**
67
+ * The epoch-ms of the next minute (strictly after `fromMs`) whose components all
68
+ * pass the masks, walking forward minute-by-minute. Returns `null` when no match
69
+ * is found within `horizonMinutes` (a safety bound; an all-zero mask is caught by
70
+ * {@link cronNeverFires} before this is called). Fires land on the :00 second of
71
+ * the matching minute.
72
+ */
73
+ export function nextCronFireMs(
74
+ masks: CronMasks,
75
+ fromMs: number,
76
+ horizonMinutes = 366 * 24 * 60,
77
+ ): number | null {
78
+ // Start at the next whole minute boundary strictly after `fromMs`.
79
+ const d = new Date(fromMs);
80
+ d.setSeconds(0, 0);
81
+ d.setMinutes(d.getMinutes() + 1);
82
+ for (let i = 0; i < horizonMinutes; i++) {
83
+ if (cronMatches(masks, d)) return d.getTime();
84
+ d.setMinutes(d.getMinutes() + 1);
85
+ }
86
+ return null;
87
+ }