toiljs 0.0.75 → 0.0.77

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.
@@ -8,7 +8,7 @@ export const TOIL_DOCS: Record<string, string> = {
8
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- Compute tiers: the server can span L1 request (`server/main.ts`, `@rest`/`@service`/`@remote`),\n L2/L3 stream (`server/main.stream.ts`, `@stream`), and L4 daemon (`server/main.daemon.ts`,\n `@daemon`/`@scheduled`); each tier compiles into its own artifact. See [tiers.md](./tiers.md).\n\nSee [routing.md](./routing.md), [client.md](./client.md), [styling.md](./styling.md),\n[server.md](./server.md), [ssr.md](./ssr.md), [rpc.md](./rpc.md), [tiers.md](./tiers.md),\n[streams.md](./streams.md), [daemon.md](./daemon.md), [derive.md](./derive.md), [cli.md](./cli.md).\n",
9
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 with production uWS/static workers, no Vite. Flags: `--root`, `--port`, `--host`, `--threads`. |\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
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",
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 meta: [{ name: \"description\", content: \"...\" }],\n});\n```\n",
12
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
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
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",
@@ -23,6 +23,9 @@ describe('Image', () => {
23
23
  expect(img.getAttribute('width')).toBe('200');
24
24
  expect(img.getAttribute('height')).toBe('100');
25
25
  expect(img.getAttribute('fetchpriority')).toBe('auto');
26
+ // A plain image adds no inline style and no toil class (nothing to lay out).
27
+ expect(img.getAttribute('style')).toBe(null);
28
+ expect(img.className).toBe('');
26
29
  });
27
30
 
28
31
  it('priority images load eagerly with high fetch priority', () => {
@@ -38,7 +41,7 @@ describe('Image', () => {
38
41
  expect(img.getAttribute('fetchpriority')).toBe('high');
39
42
  });
40
43
 
41
- it('fill drops width/height and absolutely positions the image', () => {
44
+ it('fill wraps the image in a positioned box; the image lays out via a class, not inline styles', () => {
42
45
  const { getByAltText } = render(
43
46
  <Image
44
47
  src="/bg.png"
@@ -50,8 +53,38 @@ describe('Image', () => {
50
53
  const img = getByAltText('bg') as HTMLImageElement;
51
54
  expect(img.hasAttribute('width')).toBe(false);
52
55
  expect(img.hasAttribute('height')).toBe(false);
53
- expect(img.style.position).toBe('absolute');
56
+ // The fill layout comes from a shipped, overridable CSS class, NOT inline positioning.
57
+ expect(img.classList.contains('toil-img-fill')).toBe(true);
58
+ expect(img.style.position).toBe('');
59
+ // objectFit is genuinely per-instance, so it stays inline.
54
60
  expect(img.style.objectFit).toBe('cover');
61
+ // The image is wrapped in a relatively-positioned <span> box, so it can only fill THAT box.
62
+ const box = img.parentElement as HTMLElement;
63
+ expect(box.tagName).toBe('SPAN');
64
+ expect(box.classList.contains('toil-img-fill-box')).toBe(true);
65
+ });
66
+
67
+ it('puts the caller className/size on the fill box, the fill class on the image', () => {
68
+ const { getByAltText } = render(
69
+ <Image
70
+ src="/bg.png"
71
+ alt="bg2"
72
+ fill
73
+ width={400}
74
+ height={300}
75
+ className="hero"
76
+ />,
77
+ );
78
+ const img = getByAltText('bg2') as HTMLImageElement;
79
+ const box = img.parentElement as HTMLElement;
80
+ // The caller's className + size land on the box (what they size); the image just fills it.
81
+ expect(box.classList.contains('hero')).toBe(true);
82
+ expect(box.classList.contains('toil-img-fill-box')).toBe(true);
83
+ expect(box.style.width).toBe('400px');
84
+ expect(box.style.height).toBe('300px');
85
+ expect(img.classList.contains('toil-img-fill')).toBe(true);
86
+ expect(img.getAttribute('style')).toBe(null);
87
+ expect(img.hasAttribute('width')).toBe(false);
55
88
  });
56
89
 
57
90
  it('shows a blur placeholder until the image loads', () => {
@@ -66,8 +99,10 @@ describe('Image', () => {
66
99
  />,
67
100
  );
68
101
  const img = getByAltText('p') as HTMLImageElement;
102
+ expect(img.classList.contains('toil-img-blur')).toBe(true);
69
103
  expect(img.style.backgroundImage).toContain('data:image/x');
70
104
  fireEvent.load(img);
71
105
  expect(img.style.backgroundImage).toBe('');
106
+ expect(img.classList.contains('toil-img-blur')).toBe(false);
72
107
  });
73
108
  });
@@ -32,22 +32,12 @@ describe('route head (metadata baseline)', () => {
32
32
  expect(desc()).toBe('route desc');
33
33
  });
34
34
 
35
- it("applies a layout's titleTemplate to the route's title", () => {
36
- function LayoutDefaults() {
37
- useHead({ titleTemplate: '%s · toiljs' });
38
- return null;
39
- }
40
- render(<LayoutDefaults />);
41
- setRouteHead(resolveMetadata({ title: 'About' }));
42
- expect(document.title).toBe('About · toiljs');
43
- });
44
-
45
- // Regression for the "metadata title doesn't update" report: a real layout (title + template)
35
+ // Regression for the "metadata title doesn't update" report: a real layout (title default)
46
36
  // plus a route's full `metadata` (the exact shape users write) must land on the route's title,
47
- // wrapped by the layout template, with the route's og:title applied too.
48
- it('applies a full route metadata over a layout title + template', () => {
37
+ // with the route's og:title applied too.
38
+ it('applies a full route metadata over a layout title default', () => {
49
39
  function LayoutDefaults() {
50
- useHead({ titleTemplate: '%s | ToilJS', title: 'ToilJS' });
40
+ useHead({ title: 'ToilJS' });
51
41
  return null;
52
42
  }
53
43
  render(<LayoutDefaults />);
@@ -58,21 +48,9 @@ describe('route head (metadata baseline)', () => {
58
48
  openGraph: { title: 'useReducer | React Hooks', type: 'website' },
59
49
  }),
60
50
  );
61
- expect(document.title).toBe('useReducer | React Hooks | ToilJS');
51
+ expect(document.title).toBe('useReducer | React Hooks');
62
52
  expect(
63
53
  document.head.querySelector('meta[property="og:title"]')?.getAttribute('content'),
64
54
  ).toBe('useReducer | React Hooks');
65
55
  });
66
-
67
- // A route can opt out of the layout's template by setting its own `titleTemplate: '%s'`, so the
68
- // tab reads exactly the route title with no site suffix.
69
- it("lets a route override the layout template with its own '%s'", () => {
70
- function LayoutDefaults() {
71
- useHead({ titleTemplate: '%s | ToilJS', title: 'ToilJS' });
72
- return null;
73
- }
74
- render(<LayoutDefaults />);
75
- setRouteHead(resolveMetadata({ title: 'useReducer | React Hooks', titleTemplate: '%s' }));
76
- expect(document.title).toBe('useReducer | React Hooks');
77
- });
78
56
  });
package/test/head.test.ts CHANGED
@@ -3,12 +3,9 @@ import { describe, expect, it } from 'vitest';
3
3
  import { mergeHead } from '../src/client/head/head';
4
4
 
5
5
  describe('mergeHead', () => {
6
- it('takes the last title and applies a titleTemplate', () => {
6
+ it('takes the last title', () => {
7
7
  expect(mergeHead([{ title: 'Home' }]).title).toBe('Home');
8
8
  expect(mergeHead([{ title: 'A' }, { title: 'B' }]).title).toBe('B');
9
- expect(mergeHead([{ titleTemplate: '%s · toiljs' }, { title: 'About' }]).title).toBe(
10
- 'About · toiljs',
11
- );
12
9
  });
13
10
 
14
11
  it('leaves title undefined when nothing sets it', () => {
@@ -6,7 +6,6 @@ describe('resolveMetadata', () => {
6
6
  it('expands convenience fields into meta/link tags', () => {
7
7
  const head = resolveMetadata({
8
8
  title: 'About',
9
- titleTemplate: '%s · toiljs',
10
9
  description: 'desc',
11
10
  keywords: ['a', 'b'],
12
11
  robots: 'noindex',
@@ -16,7 +15,6 @@ describe('resolveMetadata', () => {
16
15
  });
17
16
 
18
17
  expect(head.title).toBe('About');
19
- expect(head.titleTemplate).toBe('%s · toiljs');
20
18
  const byName = (name: string) => head.meta?.find((m) => m.name === name)?.content;
21
19
  const byProp = (property: string) =>
22
20
  head.meta?.find((m) => m.property === property)?.content;
@@ -392,4 +392,58 @@ describe('ssr build orchestration', () => {
392
392
  fs.rmSync(dir, { recursive: true, force: true });
393
393
  }
394
394
  });
395
+
396
+ it('bakes the route SEO (title/description/og incl og:image) into the template <head>', () => {
397
+ const art = extractRouteTemplate({
398
+ name: 'profile',
399
+ Page: ProfilePage,
400
+ layouts: [Layout],
401
+ loaderData: sample,
402
+ loaderContext: LoaderDataContext,
403
+ setSsrBuild: __setSsrBuild,
404
+ shell: SHELL,
405
+ seo: {
406
+ url: 'https://ex.com',
407
+ title: 'Site',
408
+ openGraph: { siteName: 'Site', image: 'https://ex.com/og.png' },
409
+ },
410
+ metadata: {
411
+ title: 'Profile',
412
+ description: 'A user profile',
413
+ openGraph: { title: 'Profile OG' },
414
+ },
415
+ pattern: '/u/ada',
416
+ });
417
+ const tmpl = art.tmpl.toString('utf8');
418
+ // The route's own title replaces the shell's, and its metadata + the site SEO are in <head>,
419
+ // so an SSR route serves the same per-page tags a crawler would get from <route>/index.html.
420
+ expect(tmpl).toContain('<title>Profile</title>');
421
+ expect(tmpl).not.toContain('<title>t</title>');
422
+ expect(tmpl).toContain('<meta name="description" content="A user profile" />');
423
+ expect(tmpl).toContain('<meta property="og:title" content="Profile OG" />');
424
+ expect(tmpl).toContain('<meta property="og:image" content="https://ex.com/og.png" />');
425
+ // Per-route canonical / og:url point at THIS route, not the site root.
426
+ expect(tmpl).toContain('<link rel="canonical" href="https://ex.com/u/ada" />');
427
+ expect(tmpl).toContain('<meta property="og:url" content="https://ex.com/u/ada" />');
428
+ // The baked head is inside art.tmpl, so it is covered by the coherence hash.
429
+ expect(art.hash).toHaveLength(32);
430
+ // Holes are unaffected (still body-only).
431
+ expect(art.slotCount).toBe(3);
432
+ });
433
+
434
+ it('skips SEO injection when no seo config is passed (head unchanged)', () => {
435
+ const art = extractRouteTemplate({
436
+ name: 'p',
437
+ Page: ProfilePage,
438
+ layouts: [Layout],
439
+ loaderData: sample,
440
+ loaderContext: LoaderDataContext,
441
+ setSsrBuild: __setSsrBuild,
442
+ shell: SHELL,
443
+ });
444
+ const tmpl = art.tmpl.toString('utf8');
445
+ expect(tmpl).toContain('<title>t</title>'); // shell title untouched
446
+ expect(tmpl).not.toContain('og:image');
447
+ expect(tmpl).not.toContain('rel="canonical"');
448
+ });
395
449
  });