toiljs 0.0.85 → 0.0.87

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 (132) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +303 -293
  5. package/build/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/config.d.ts +2 -0
  7. package/build/compiler/config.js +1 -0
  8. package/build/compiler/docs.js +8 -24
  9. package/build/compiler/generate.js +4 -2
  10. package/build/compiler/index.d.ts +1 -1
  11. package/build/compiler/index.js +69 -6
  12. package/build/compiler/toil-docs.generated.js +64 -22
  13. package/build/devserver/.tsbuildinfo +1 -1
  14. package/build/devserver/analytics/index.js +7 -3
  15. package/build/devserver/db/database.d.ts +4 -0
  16. package/build/devserver/db/database.js +43 -1
  17. package/build/devserver/db/index.d.ts +1 -1
  18. package/build/devserver/db/index.js +1 -1
  19. package/build/devserver/db/types.d.ts +3 -0
  20. package/build/devserver/db/types.js +2 -0
  21. package/build/devserver/runtime/module.js +4 -1
  22. package/docs/README.md +104 -65
  23. package/docs/auth/README.md +102 -0
  24. package/docs/auth/configuration.md +94 -0
  25. package/docs/auth/extending.md +202 -0
  26. package/docs/auth/how-it-works.md +138 -0
  27. package/docs/auth/usage.md +188 -0
  28. package/docs/backend/README.md +143 -0
  29. package/docs/backend/data.md +351 -0
  30. package/docs/backend/rest.md +402 -0
  31. package/docs/backend/rpc.md +226 -0
  32. package/docs/background/README.md +114 -0
  33. package/docs/background/daemons.md +230 -0
  34. package/docs/background/derive.md +179 -0
  35. package/docs/cli/README.md +377 -0
  36. package/docs/concepts/config.md +416 -0
  37. package/docs/concepts/decorators.md +127 -0
  38. package/docs/concepts/security.md +108 -0
  39. package/docs/concepts/tiers.md +166 -0
  40. package/docs/concepts/types.md +216 -0
  41. package/docs/database/README.md +143 -0
  42. package/docs/database/capacity.md +350 -0
  43. package/docs/database/counters.md +174 -0
  44. package/docs/database/documents.md +255 -0
  45. package/docs/database/events.md +307 -0
  46. package/docs/database/membership.md +246 -0
  47. package/docs/database/setup.md +155 -0
  48. package/docs/database/unique.md +216 -0
  49. package/docs/database/views.md +246 -0
  50. package/docs/frontend/README.md +101 -0
  51. package/docs/frontend/data-fetching.md +243 -0
  52. package/docs/frontend/images.md +148 -0
  53. package/docs/frontend/metadata.md +344 -0
  54. package/docs/frontend/rendering.md +236 -0
  55. package/docs/frontend/routing.md +344 -0
  56. package/docs/frontend/scripts.md +118 -0
  57. package/docs/frontend/search.md +191 -0
  58. package/docs/frontend/styling.md +147 -0
  59. package/docs/getting-started/README.md +81 -0
  60. package/docs/getting-started/create-project.md +131 -0
  61. package/docs/getting-started/deploy.md +101 -0
  62. package/docs/getting-started/first-app.md +215 -0
  63. package/docs/getting-started/installation.md +106 -0
  64. package/docs/getting-started/migrating.md +125 -0
  65. package/docs/getting-started/project-structure.md +163 -0
  66. package/docs/introduction/README.md +41 -0
  67. package/docs/introduction/design-principles.md +55 -0
  68. package/docs/introduction/distributed.md +74 -0
  69. package/docs/introduction/how-it-works.md +74 -0
  70. package/docs/introduction/hyperscale.md +51 -0
  71. package/docs/introduction/modern-stack.md +36 -0
  72. package/docs/introduction/vs-other-frameworks.md +48 -0
  73. package/docs/introduction/why-toil.md +93 -0
  74. package/docs/llms.txt +90 -0
  75. package/docs/realtime/README.md +102 -0
  76. package/docs/realtime/channels.md +211 -0
  77. package/docs/realtime/streams.md +369 -0
  78. package/docs/services/README.md +60 -0
  79. package/docs/services/analytics.md +268 -0
  80. package/docs/services/caching.md +175 -0
  81. package/docs/services/cookies.md +235 -0
  82. package/docs/services/crypto.md +209 -0
  83. package/docs/services/email.md +289 -0
  84. package/docs/services/environment.md +141 -0
  85. package/docs/services/ratelimit.md +174 -0
  86. package/docs/services/time.md +85 -0
  87. package/examples/basic/client/routes/analytics.tsx +13 -12
  88. package/examples/basic/server/models/SiteAnalytics.ts +29 -17
  89. package/examples/basic/server/routes/Analytics.ts +15 -17
  90. package/examples/basic/server/routes/UserId.ts +22 -0
  91. package/package.json +15 -11
  92. package/scripts/gen-toil-docs.mjs +24 -35
  93. package/server/auth/AuthController.ts +336 -0
  94. package/server/auth/AuthUser.ts +23 -0
  95. package/server/auth/index.ts +16 -0
  96. package/server/globals/auth.ts +31 -0
  97. package/server/globals/userid.ts +107 -0
  98. package/src/compiler/config.ts +13 -0
  99. package/src/compiler/docs.ts +16 -33
  100. package/src/compiler/generate.ts +6 -2
  101. package/src/compiler/index.ts +114 -6
  102. package/src/compiler/toil-docs.generated.ts +64 -22
  103. package/src/devserver/analytics/index.ts +10 -4
  104. package/src/devserver/db/database.ts +67 -1
  105. package/src/devserver/db/index.ts +1 -0
  106. package/src/devserver/db/types.ts +13 -0
  107. package/src/devserver/runtime/module.ts +7 -0
  108. package/test/analytics-dev.test.ts +2 -1
  109. package/test/devserver-database.test.ts +113 -0
  110. package/docs/auth-todo.md +0 -149
  111. package/docs/auth.md +0 -322
  112. package/docs/caching.md +0 -115
  113. package/docs/cli.md +0 -17
  114. package/docs/client.md +0 -39
  115. package/docs/cookies.md +0 -457
  116. package/docs/crypto.md +0 -130
  117. package/docs/daemon.md +0 -123
  118. package/docs/data.md +0 -131
  119. package/docs/derive.md +0 -159
  120. package/docs/email.md +0 -326
  121. package/docs/environment.md +0 -97
  122. package/docs/getting-started.md +0 -128
  123. package/docs/index.md +0 -30
  124. package/docs/ratelimit.md +0 -95
  125. package/docs/routing.md +0 -259
  126. package/docs/rpc.md +0 -149
  127. package/docs/server.md +0 -61
  128. package/docs/ssr.md +0 -632
  129. package/docs/streams.md +0 -178
  130. package/docs/styling.md +0 -22
  131. package/docs/tiers.md +0 -133
  132. package/docs/time.md +0 -43
package/docs/ssr.md DELETED
@@ -1,632 +0,0 @@
1
- # Server-side rendering (SSR)
2
-
3
- toiljs server-renders a route by splitting it into two halves that the build
4
- keeps coherent:
5
-
6
- - **the template**, the HTML shell with the dynamic bits punched out (named
7
- *holes*). It is React's own `renderToStaticMarkup` output with the holes
8
- removed, precompiled at build time and held (mmap'd) by the edge.
9
- - **the values**, the hole values for one request (text, attributes, repeated
10
- rows, headers, status). The wasm guest's `render` entrypoint returns *only*
11
- these. The edge splices them into the template.
12
-
13
- A 32-byte template **hash** travels with the values so the edge can reject a
14
- guest that was compiled against a different template than the one deployed.
15
-
16
- This split is the whole point. The guest never re-runs React and never emits the
17
- static page bytes, it stamps a tiny `(slot_id, kind, value)` list, so the wasm
18
- stays small and a request is served about as fast as a static file, while still
19
- delivering real first-paint HTML and SEO. The browser then hydrates the spliced
20
- markup in place with no flash and no client re-render, because the holes are
21
- escaped exactly the way React escapes them, so the bytes match.
22
-
23
- This page is about server-rendered HTML. JSON / binary API endpoints use
24
- [Routing](./routing.md) and `@rest` (see [Server](./server.md)) instead.
25
-
26
- The running example throughout is the basic example's `/hello` route:
27
-
28
- - `examples/basic/client/routes/hello.tsx`, the route (`ssr = true`, holes, loader)
29
- - `examples/basic/server/SsrHelloRender.ts`, the server `render` + `Ssr.register`
30
- - `examples/basic/server/_ssr/hello.slots.ts`, the generated `Slot` + `HASH` (gitignored, never hand-edited)
31
-
32
- ---
33
-
34
- ## 1. Authoring an SSR route
35
-
36
- Opt a route in with `export const ssr = true`. At build time toiljs renders the
37
- page ONCE (under its real layout chain, with sample loader data) into the
38
- template, generates the route's typed `Slot` module, and writes the template
39
- manifest the edge serves. Routes without `ssr = true` are untouched and render
40
- purely on the client as before.
41
-
42
- Mark the dynamic bits with the four hole markers from `toiljs/client`. They are
43
- **transparent in the browser**, `<Hole>` renders its children, `<Repeat>`
44
- renders `each.map(...)`, `<RawHtml>` renders a `dangerouslySetInnerHTML`
45
- wrapper, `<Island>` renders its children, so the same component is your normal
46
- client UI. Only the build extractor and the server `render` treat them
47
- specially.
48
-
49
- ```tsx
50
- import { Hole, Island, RawHtml, Repeat, useLoaderData } from 'toiljs/client';
51
-
52
- export const ssr = true;
53
-
54
- export const loader = ({ params }: { params: Record<string, string> }) => ({
55
- name: params.name ?? 'world',
56
- blurbHtml: 'Rendered at the <strong>edge</strong>.',
57
- services: [{ name: 'record', region: 'us-east' }],
58
- });
59
-
60
- export default function Hello() {
61
- const d = useLoaderData<typeof loader>();
62
- return (
63
- <section className="hello">
64
- <h1>Hello, <Hole id="name">{d.name}</Hole>!</h1>
65
-
66
- <p><RawHtml id="blurb" html={d.blurbHtml} as="span" /></p>
67
-
68
- <ul>
69
- <Repeat id="services" each={d.services}>
70
- {(s) => (
71
- <li>
72
- <strong><Hole id="svcName">{s.name}</Hole></strong>
73
- <span className="hello-region"><Hole id="svcRegion">{s.region}</Hole></span>
74
- </li>
75
- )}
76
- </Repeat>
77
- </ul>
78
-
79
- <Island>
80
- <p>Hydrated at {new Date().toLocaleTimeString()}.</p>
81
- </Island>
82
- </section>
83
- );
84
- }
85
- ```
86
-
87
- ### The loader at build time
88
-
89
- The build calls your `loader` once with synthesized sample params to obtain
90
- representative data, then renders the page with it. Only the **shape** of the
91
- data matters at build time, it drives which holes exist and (for `<Repeat>`)
92
- captures the row sub-template. The real per-request values come from the
93
- **server** `render`, not from this loader. Note in particular that `<Repeat>`
94
- needs the sample to have **at least one row** so the build can capture the row
95
- markup; an empty array at build time leaves nothing to stamp.
96
-
97
- ### The four hole markers
98
-
99
- | Marker | Prop(s) | Server (build + render) | Browser |
100
- | --- | --- | --- | --- |
101
- | `<Hole id>` | `id` | a text insertion point; the guest fills it with the **escaped** value | renders `children` |
102
- | `<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>` |
103
- | `<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)` |
104
- | `<Island>` | `children` | renders **nothing** (empty in the server HTML) | renders `children` |
105
-
106
- `<RawHtml>` always needs a host element so the server and client DOM agree; that
107
- is the `as` wrapper (defaults to `div`). The captured `<as>` tag is part of the
108
- template, only its inner HTML is a hole.
109
-
110
- ### Attribute holes (`attr()`)
111
-
112
- An attribute value is not a child node, so it cannot be a JSX marker element.
113
- Use the `attr(id, value)` helper from `toiljs/client` in attribute position
114
- instead:
115
-
116
- ```tsx
117
- import { attr } from 'toiljs/client';
118
-
119
- <a href={attr('profileUrl', d.url)} class={attr('cls', d.cls)}>…</a>
120
- ```
121
-
122
- Browser: `attr` returns `value` unchanged. Build: it emits an `attr` hole at the
123
- attribute's byte offset (stripping to an empty value in the `.tmpl`). The server
124
- `render` fills it with `v.setAttr(Slot.profileUrl, url)` (React-escaped, the same
125
- as `setText`), and the host splices it between the quotes. It composes with
126
- literal text in the same attribute (`` class={`btn ${attr('x', v)}`} ``).
127
-
128
- ### SSR-safe routes (and `<Island>`)
129
-
130
- For hydration to be byte-clean, the route **and the layouts above it** must
131
- render under static markup: use the hole markers and `useLoaderData`, and put
132
- anything that needs router hooks (`useRouter`, `usePathname`, …) or browser-only
133
- APIs (`window`, `Date.now`, …) inside an `<Island>`. An island is empty in the
134
- first paint and appears only after hydration, so it gets no first-paint HTML or
135
- SEO, which is exactly right for "client only" content.
136
-
137
- Opting in is always safe: a route (or a layout in its chain) that **throws**
138
- under static markup is **skipped at build with a warning** and falls back to
139
- normal client rendering. You never get a broken page from adding `ssr = true`;
140
- worst case you get client rendering and a build warning telling you why.
141
-
142
- ---
143
-
144
- ## 2. The server `render`
145
-
146
- The wasm `render(req_ofs, req_len) -> i64` export is surfaced by your
147
- `server/main.ts` via `export * from 'toiljs/server/runtime/exports'` (the same
148
- line that surfaces `handle`). At request time it decodes the request, runs the
149
- `Ssr` router to find a matching render function, serializes that function's
150
- `SlotValues` into the values envelope, and returns it packed as
151
- `(ptr << 32) | len`.
152
-
153
- A render function takes the `Request`, returns a filled `SlotValues` for a path
154
- it owns, or `null` to let the next registered renderer try.
155
-
156
- ```ts
157
- import { HtmlBuilder, Request, SlotValues, Ssr } from 'toiljs/server/runtime';
158
- import { HASH, Slot } from './_ssr/hello.slots';
159
-
160
- function renderHello(req: Request): SlotValues | null {
161
- // The guest re-derives WHICH route this is from the path (the template name
162
- // is not in the request envelope), exactly as a @rest controller matches its
163
- // own prefix. req.path includes the query string, so match both forms.
164
- if (req.path != '/hello' && !req.path.startsWith('/hello?')) return null;
165
-
166
- const v = new SlotValues(HASH);
167
-
168
- v.setText(Slot.name, greetingName(req)); // escaped
169
- v.setRaw(Slot.blurb, 'Rendered at the <strong>edge</strong>.'); // verbatim
170
-
171
- const rows = new HtmlBuilder();
172
- for (let i = 0; i < services.length; i++) {
173
- const s = services[i];
174
- rows.raw('<li><strong>').text(s.name)
175
- .raw('</strong><span class="hello-region">').text(s.region)
176
- .raw('</span></li>');
177
- }
178
- v.setRepeat(Slot.services, rows);
179
-
180
- return v;
181
- }
182
-
183
- Ssr.register(renderHello); // side-effect registration
184
- ```
185
-
186
- ### Registration is manual; the import is load-bearing
187
-
188
- `ssr-codegen` generates ONLY the `Slot` enum and the `HASH`, it does **not**
189
- emit the render body and does **not** auto-register it. You write `renderHello`
190
- and call `Ssr.register(renderHello)` yourself.
191
-
192
- Crucially, `Ssr.register` runs as a **module side effect**, so the module must
193
- be imported somewhere the build reaches. Non-surface files (a plain render
194
- module is not a `@rest`/`@service`/`@data` file) are **not** auto-discovered, so
195
- you must `import './SsrHelloRender'` in `server/main.ts`. Forgetting the import
196
- means the renderer never registers, `Ssr.dispatch` returns `null`, and the route
197
- falls back to the fail-safe 500.
198
-
199
- ### What `render` does for a request the router misses
200
-
201
- If no registered renderer matches, the `render` export emits a **fail-safe**
202
- envelope: status 500 with a **zeroed** 32-byte hash and no slots (a malformed
203
- request envelope yields the same fail-safe with status 400). The edge rejects
204
- the zero hash as a coherence mismatch, so a miss surfaces as a clean error
205
- rather than a corrupt page.
206
-
207
- ---
208
-
209
- ## 3. Reference: `SlotValues`
210
-
211
- Construct it with the route's compiled-in hash: `new SlotValues(HASH)`. Each
212
- setter targets a slot id (a `Slot` enum member); the **kind** determines how the
213
- edge splices it. All setters return `this` for chaining.
214
-
215
- | Method | Signature | Escaping | Use |
216
- | --- | --- | --- | --- |
217
- | `setText` | `setText(slotId, value: string)` | **React-escaped** | text content (safe by default) |
218
- | `setRaw` | `setRaw(slotId, html: string)` | **none (verbatim)** | raw HTML, *you* sanitize |
219
- | `setAttr` | `setAttr(slotId, value: string)` | **React-escaped** | an attribute value |
220
- | `setRepeat` | `setRepeat(slotId, rows: HtmlBuilder)` | per `HtmlBuilder` calls | a repeat region, pre-stamped row by row |
221
- | `setHeader` | `setHeader(name, value)` |, | a response header (e.g. `Cache-Control`, `Set-Cookie`) |
222
- | `setStatus` | `setStatus(code)` |, | the HTTP status (default 200) |
223
-
224
- `setText` and `setAttr` escape identically (React escapes text and attributes
225
- the same way). Slot ids passed are the `Slot` enum members; AS enums are `i32`,
226
- so they pass without a cast and are narrowed to `u16` only at encode time.
227
-
228
- ### `HtmlBuilder`
229
-
230
- Assembles a repeat region (or any HTML fragment) with the same escaping
231
- guarantees. Chain `raw` (verbatim template bytes) and `text` / `attr`
232
- (React-escaped values):
233
-
234
- ```ts
235
- const rows = new HtmlBuilder();
236
- for (let i = 0; i < items.length; i++) {
237
- rows.raw('<li>').text(items[i]).raw('</li>'); // items[i] is escaped
238
- }
239
- v.setRepeat(Slot.list, rows);
240
- ```
241
-
242
- You are hand-writing the row markup, so it must match what `<Repeat>`'s child
243
- produces for the same item, the build captured exactly that markup as the row
244
- sub-template, and the edge inserts your stamped rows verbatim at the region
245
- offset. Keep the **structure** the same across rows; only the leaf hole values
246
- vary.
247
-
248
- | Method | Signature | Escaping |
249
- | --- | --- | --- |
250
- | `raw` | `raw(s: string): HtmlBuilder` | verbatim |
251
- | `text` | `text(s: string): HtmlBuilder` | React-escaped |
252
- | `attr` | `attr(s: string): HtmlBuilder` | React-escaped (identical to `text`) |
253
-
254
- ---
255
-
256
- ## 4. Escaping (React-exact)
257
-
258
- `setText`, `setAttr`, and `HtmlBuilder.text` / `.attr` escape **exactly as
259
- React does** (`react-dom/server`'s `escapeTextForBrowser`, regex `/["'&<>]/`),
260
- so the server-rendered markup and the client hydration agree byte-for-byte:
261
-
262
- ```
263
- " → &quot; & → &amp; ' → &#x27;
264
- < → &lt; > → &gt;
265
- ```
266
-
267
- The detail that bites: `'` becomes `&#x27;` (React's exact choice), **not**
268
- `&#39;`. If your escaping deviates from this by even one entity, `hydrateRoot`
269
- sees different markup and React throws a hydration mismatch and re-renders the
270
- subtree. The guest's `escapeHtml` and the build's `reactEscapeHtml` are pinned
271
- to be byte-identical for exactly this reason.
272
-
273
- `setRaw` and `HtmlBuilder.raw` do **not** escape, they insert your bytes
274
- verbatim. That is the right tool for markup you produced or sanitized yourself
275
- (the same contract as `dangerouslySetInnerHTML`), and the wrong tool for
276
- anything derived from request input.
277
-
278
- ---
279
-
280
- ## 5. The build flow and generated artifacts
281
-
282
- `extractTemplates` (driven by `toiljs build`) does, for each `ssr = true` route:
283
-
284
- 1. loads the route + its layout chain through a short-lived Vite SSR server;
285
- 2. calls the `loader` with sample params to get representative data;
286
- 3. renders the page under its layouts with the markers in **sentinel mode**
287
- (`__setSsrBuild(true)`), each marker emits a Private-Use-Area sentinel token
288
- instead of rendering normally;
289
- 4. splices that into the built shell's `<div id="root">` and adds the SSR marker
290
- `<template id="__toil_ssr"></template>` (this is what the client `mount` looks
291
- for to switch to `hydrateRoot`);
292
- 5. strips the sentinel tokens, records their **byte offsets**, and writes the
293
- artifacts.
294
-
295
- ### Where the artifacts land
296
-
297
- For a route named `<name>` (see below), under `build/client/_ssr/`:
298
-
299
- | File | Consumer | Contents |
300
- | --- | --- | --- |
301
- | `<name>.tmpl` | edge host (mmap'd) | the stripped static HTML shell, holes removed |
302
- | `<name>.slots` | edge host | the binary manifest (offsets, ids, kinds, tmpl_len, hash) |
303
- | `<name>.slots.ts` | guest build | the generated `Slot` enum + `HASH` AssemblyScript module |
304
- | `templates.json` | index | `[{ route, name, hash }]` for every extracted template |
305
-
306
- The `.tmpl` and `.slots` are then **copied** into the edge host bundle at
307
- `hosts/edge/_tmpl/<name>.{tmpl,slots}`.
308
-
309
- The build also writes the **server-importable** `Slot` + `HASH` module to
310
- `server/_ssr/<name>.slots.ts`, the one your `render` imports. It is generated
311
- and gitignored; never hand-edit it (see the two-pass note below).
312
-
313
- ### Route name derivation (`routeTemplateName`)
314
-
315
- The `<name>` is a file-safe slug of the route pattern: non-alphanumerics collapse
316
- to `_`, leading/trailing `_` are trimmed, empty → `index`.
317
-
318
- | Route pattern | `<name>` |
319
- | --- | --- |
320
- | `/hello` | `hello` |
321
- | `/` | `index` |
322
- | `/u/:name` | `u_name` |
323
- | `/blog/[id]` | `blog_id` |
324
-
325
- ### The two-pass build: no hand-kept slots
326
-
327
- The final extraction runs **after** the Vite client build (the built shell's
328
- hashed `<script>`/`<link>` tags are part of the template, so they must be in the
329
- `HASH`), but the server (guest wasm) is compiled **before** it. A naive build
330
- therefore can't generate `<name>.slots.ts` in time for the `render` to import
331
- it. toiljs closes this with a **two-pass** build, so a clean build needs **zero
332
- hand-maintained slots**:
333
-
334
- 1. **Slots pre-pass** (before the server build) renders every `ssr = true`
335
- route to its `Slot` enum + `HASH` and writes the server-importable module at
336
- `server/_ssr/<name>.slots.ts`. This is the file your `render` imports, it is
337
- **generated, gitignored, and never hand-edited**.
338
- 2. The server compiles against that module.
339
- 3. The client (Vite) builds.
340
- 4. **Final extraction** re-renders against the real built shell and rewrites
341
- `server/_ssr/<name>.slots.ts` with the authoritative `HASH`. If the hash
342
- rotated since the pre-pass, the build recompiles the server **once** so the
343
- guest bakes the deployed hash.
344
-
345
- So authoring an SSR route is just the route + the `render`; the `Slot` / `HASH`
346
- module is entirely build-managed. (On an unchanged rebuild the pre-pass reuses
347
- the prior build's shell, so the hashes already match and step 4's recompile is
348
- skipped.)
349
-
350
- ---
351
-
352
- ## 6. Hash coherence and the values envelope
353
-
354
- Every values envelope carries the guest's compiled-in 32-byte `HASH`. The edge
355
- compares it against the deployed template's hash and **rejects a mismatch** with
356
- a fail-safe 500 rather than splicing values into the wrong template. A mismatch
357
- means deploy skew: the guest was built against one version of the template and a
358
- different one is deployed.
359
-
360
- The hash is `sha256(tmpl || \0 || canonicalManifest(slots))`, so any change to
361
- the static HTML, a hole's id/kind, or the repeat nesting rotates it.
362
-
363
- The guest serializes `SlotValues` to this little-endian, no-padding layout (the
364
- edge decodes it and splices against the template manifest):
365
-
366
- ```
367
- u16 status
368
- [32] template_hash
369
- u16 n_headers
370
- for each header: u16 name_len, u16 val_len, name bytes, val bytes
371
- u16 n_slots
372
- for each value: u16 slot_id, u8 kind, u32 value_len, value bytes
373
- ```
374
-
375
- `kind` is `0=text, 1=raw, 2=attr, 3=repeat`. The host keys values by `slot_id`
376
- and inserts each at the **manifest-fixed** offset, so the guest can never choose
377
- *where* bytes land, only what they are. If a value cannot be represented
378
- (a count or length overflows its field width, or the hash is the wrong size),
379
- the encoder writes the same fail-safe 500/zero-hash envelope instead of corrupt
380
- bytes.
381
-
382
- The matching `.slots` manifest the host reads is a 46-byte header
383
- (`"TSLT"` magic, u16 version, u16 flags, u32 tmpl_len, 32-byte hash, u16 n_slots)
384
- followed by 8-byte entries (`u32 offset, u16 slot_id, u8 kind, u8 reserved`).
385
-
386
- ---
387
-
388
- ## 7. Dev server and testing
389
-
390
- `toiljs dev` serves SSR routes the same way the edge does. It runs the **real**
391
- `render` export (`WasmServerModule.dispatchRender`), decodes the values
392
- envelope, and splices the values into the route's template, so you get real
393
- server-rendered HTML locally (`curl` a route, or view source), which then
394
- hydrates in place. The dev template is extracted once at startup against the
395
- live (Vite-transformed) dev shell rather than a built one; a route's per-request
396
- **values** are always live, but a change to its **markup** needs a dev restart
397
- to re-extract. A fail-safe envelope (no renderer matched) falls back to client
398
- rendering.
399
-
400
- The end-to-end test (`test/ssr-render.test.ts`) drives the same `dispatchRender`
401
- path directly: it calls `dispatchRender({ path: '/hello' })`, decodes the
402
- envelope, asserts the slots, and splices against the built `hello.tmpl`.
403
-
404
- ---
405
-
406
- ## 8. Complete worked example: `/hello`
407
-
408
- This is the full, copy-pasteable chain. All four files are real and tested.
409
-
410
- ### `client/routes/hello.tsx` (the route)
411
-
412
- ```tsx
413
- import { Hole, Island, RawHtml, Repeat, useLoaderData } from 'toiljs/client';
414
-
415
- export const ssr = true;
416
-
417
- export const metadata: Toil.Metadata = {
418
- title: 'Edge SSR',
419
- description: 'A server-rendered greeting, filled at the edge.',
420
- };
421
-
422
- interface Service { name: string; region: string; }
423
- interface GreetingData {
424
- name: string;
425
- blurbHtml: string;
426
- services: Service[];
427
- }
428
-
429
- // Build-time sample data, only the SHAPE matters; the real per-request values
430
- // come from the SERVER render. The repeat sample needs at least one row.
431
- export const loader = ({ params }: { params: Record<string, string> }): GreetingData => ({
432
- name: params.name ?? 'world',
433
- blurbHtml: 'Rendered at the <strong>edge</strong> from a tiny values envelope.',
434
- services: [
435
- { name: 'record', region: 'us-east' },
436
- { name: 'unique', region: 'eu-west' },
437
- { name: 'counter', region: 'ap-south' },
438
- ],
439
- });
440
-
441
- export default function Hello(): React.JSX.Element {
442
- const d = useLoaderData<typeof loader>();
443
- return (
444
- <section className="hello">
445
- <h1>Hello, <Hole id="name">{d.name}</Hole>!</h1>
446
-
447
- <p className="hello-blurb">
448
- <RawHtml id="blurb" html={d.blurbHtml} as="span" />
449
- </p>
450
-
451
- <h2>Service snapshot</h2>
452
- <ul className="hello-services">
453
- <Repeat id="services" each={d.services}>
454
- {(s: Service) => (
455
- <li>
456
- <strong><Hole id="svcName">{s.name}</Hole></strong>
457
- <span className="hello-region"><Hole id="svcRegion">{s.region}</Hole></span>
458
- </li>
459
- )}
460
- </Repeat>
461
- </ul>
462
-
463
- <Island>
464
- <p className="hello-island">
465
- Hydrated in your browser at {new Date().toLocaleTimeString()}.
466
- </p>
467
- </Island>
468
- </section>
469
- );
470
- }
471
- ```
472
-
473
- ### `server/_ssr/hello.slots.ts` (generated by the build; do not edit)
474
-
475
- ```ts
476
- // AUTO-GENERATED by toil (edge SSR). Do not edit.
477
-
478
- /** Stable hole ids for this route's template (document order). */
479
- export enum Slot {
480
- name = 0,
481
- blurb = 1,
482
- services = 2,
483
- }
484
-
485
- /** Coherence hash (32 bytes), written by the build's slots pre-pass; the host
486
- * rejects a response whose hash != the deployed template. */
487
- export const HASH: StaticArray<u8> = [
488
- 0xcb, 0x12, 0x5e, 0x19, 0x46, 0x32, 0x58, 0x25, 0xd3, 0xf0, 0x44, 0xc5, 0x41, 0x0c, 0x34, 0x3b,
489
- 0x69, 0xd3, 0x62, 0xb3, 0x24, 0x25, 0x79, 0xc4, 0x76, 0x89, 0xfb, 0x25, 0x6e, 0x35, 0x02, 0x31,
490
- ];
491
- ```
492
-
493
- (Only the **top-level** holes get a `Slot` id, `name`, `blurb`, `services`. The
494
- nested `svcName` / `svcRegion` live inside the repeat row sub-template, which the
495
- guest stamps with `HtmlBuilder`, so they are not separate slots.)
496
-
497
- ### `server/SsrHelloRender.ts` (the render)
498
-
499
- ```ts
500
- import { HtmlBuilder, Request, SlotValues, Ssr } from 'toiljs/server/runtime';
501
- import { HASH, Slot } from './_ssr/hello.slots';
502
-
503
- class Service {
504
- constructor(public name: string, public region: string) {}
505
- }
506
-
507
- /** Pull `?name=...`, defaulting to `world` (matches the route loader default). */
508
- function greetingName(req: Request): string {
509
- const q = req.path.indexOf('?');
510
- if (q < 0) return 'world';
511
- const parts = req.path.substring(q + 1).split('&');
512
- for (let i = 0; i < parts.length; i++) {
513
- if (parts[i].startsWith('name=')) {
514
- const v = parts[i].substring(5);
515
- return v.length > 0 ? v : 'world';
516
- }
517
- }
518
- return 'world';
519
- }
520
-
521
- function renderHello(req: Request): SlotValues | null {
522
- if (req.path != '/hello' && !req.path.startsWith('/hello?')) return null;
523
-
524
- const v = new SlotValues(HASH);
525
-
526
- // Text hole, React-escaped (so ?name=<a>&b is safe).
527
- v.setText(Slot.name, greetingName(req));
528
-
529
- // Raw hole, verbatim; a fixed, trusted blurb (no request data).
530
- v.setRaw(Slot.blurb, 'Rendered at the <strong>edge</strong> from a tiny values envelope.');
531
-
532
- // Repeat, stamp the captured row markup once per item. The row sub-template
533
- // is <li><strong>{svcName}</strong><span class="hello-region">{svcRegion}</span></li>;
534
- // .text(...) escapes each nested hole exactly as React does.
535
- const services: Service[] = [
536
- new Service('record', 'us-east'),
537
- new Service('unique', 'eu-west'),
538
- new Service('counter', 'ap-south'),
539
- ];
540
- const rows = new HtmlBuilder();
541
- for (let i = 0; i < services.length; i++) {
542
- const s = services[i];
543
- rows.raw('<li><strong>').text(s.name)
544
- .raw('</strong><span class="hello-region">').text(s.region)
545
- .raw('</span></li>');
546
- }
547
- v.setRepeat(Slot.services, rows);
548
-
549
- return v;
550
- }
551
-
552
- // Side-effect registration: main.ts imports this module so the build compiles
553
- // it in and this renderer joins the SSR router.
554
- Ssr.register(renderHello);
555
- ```
556
-
557
- ### `server/main.ts` (the load-bearing import)
558
-
559
- ```ts
560
- import { Server } from 'toiljs/server/runtime';
561
- // ... other surface imports ...
562
-
563
- // Edge SSR: importing the render module compiles it in and self-registers its
564
- // /hello renderer. Without this import the renderer never registers.
565
- import './SsrHelloRender';
566
-
567
- Server.handler = () => new AppHandler();
568
-
569
- export * from 'toiljs/server/runtime/exports'; // surfaces `handle` AND `render`
570
- ```
571
-
572
- The spliced first-paint HTML for `GET /hello` is byte-identical to what React
573
- renders for the same data:
574
-
575
- ```html
576
- <section class="hello"><h1>Hello, world!</h1>
577
- <p class="hello-blurb"><span>Rendered at the <strong>edge</strong> from a tiny values envelope.</span></p>
578
- <h2>Service snapshot</h2>
579
- <ul class="hello-services">
580
- <li><strong>record</strong><span class="hello-region">us-east</span></li>
581
- <li><strong>unique</strong><span class="hello-region">eu-west</span></li>
582
- <li><strong>counter</strong><span class="hello-region">ap-south</span></li>
583
- </ul></section>
584
- ```
585
-
586
- The `<Island>` is empty here (no first paint); it fills in after hydration.
587
-
588
- ---
589
-
590
- ## 9. Pitfalls and debugging
591
-
592
- - **Route skipped at build (warning, no SSR).** The route or a layout above it
593
- threw under static markup, almost always a router hook (`useRouter`,
594
- `usePathname`, …) or a browser-only API rendered outside an `<Island>`. The
595
- build prints `toil: SSR skipped <pattern> (...)` and the route falls back to
596
- client rendering. Move the offending content into an `<Island>`.
597
-
598
- - **Hash mismatch / clean 500 after editing a template.** Any change to the
599
- page's static markup, a hole id/kind, or the repeat structure rotates the
600
- `HASH`. The host rejects a stale guest hash. A normal `toiljs build`
601
- regenerates `server/_ssr/<name>.slots.ts` and rebakes the guest, so this only
602
- surfaces from a partial or stale deploy (a guest built against a different
603
- template than the one deployed), never from hand-copied slots.
604
-
605
- - **Hydration mismatch (flash / React re-render in the browser).** Two common
606
- causes. (1) The client **loader** does not reproduce the values the server
607
- `render` stamped. Hydration re-renders the route with the loader's data, so for
608
- any request-derived hole the loader must derive the **same** value the server
609
- `render` does (e.g. read the same `?query` / param). The two are separate
610
- sources (the client loader is TypeScript; the server `render` is the wasm
611
- guest), so keeping them in sync is the author's contract; if the client cannot
612
- reproduce a value, put that content in an `<Island>`. (2) A marker (or a
613
- non-static node) rendered outside an `<Island>`, or hole escaping that does not
614
- match React's (e.g. emitting `&#39;` instead of `&#x27;`, or using `setRaw`
615
- where the client would escape). Keep dynamic text in `<Hole>` / `setText` and
616
- client-only content in `<Island>`.
617
-
618
- - **Route renders client-side only even though `ssr = true`.** You forgot to
619
- `import './SsrHelloRender'` in `server/main.ts`, so `Ssr.register` never ran
620
- and `Ssr.dispatch` returns `null`. Add the import. (Plain render modules are
621
- not auto-discovered the way `@rest`/`@service` files are.)
622
-
623
- - **`setRaw` injecting unsanitized request data.** `setRaw` is verbatim, never
624
- pass it anything derived from request input you have not sanitized. Use
625
- `setText` for request-derived text.
626
-
627
- - **Empty `<Repeat>` sample at build.** The build captures the row sub-template
628
- from the **first** sample row. If your build-time `loader` returns an empty
629
- array for a `<Repeat>`, there is no row to capture. Give the build sample at
630
- least one representative row.
631
- </content>
632
- </invoke>