toiljs 0.0.60 → 0.0.62

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 (120) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/CHANGELOG.md +17 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +2 -2
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/index.d.ts +1 -1
  7. package/build/client/index.js +1 -1
  8. package/build/client/routing/mount.js +11 -26
  9. package/build/client/ssr/markers.d.ts +1 -0
  10. package/build/client/ssr/markers.js +9 -2
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/config.d.ts +21 -0
  13. package/build/compiler/config.js +35 -0
  14. package/build/compiler/docs.d.ts +2 -1
  15. package/build/compiler/docs.js +33 -304
  16. package/build/compiler/index.d.ts +13 -0
  17. package/build/compiler/index.js +113 -21
  18. package/build/compiler/template-build.d.ts +23 -3
  19. package/build/compiler/template-build.js +120 -30
  20. package/build/compiler/toil-docs.generated.d.ts +1 -0
  21. package/build/compiler/toil-docs.generated.js +20 -0
  22. package/build/devserver/.tsbuildinfo +1 -1
  23. package/build/devserver/daemon/catalog.d.ts +26 -0
  24. package/build/devserver/daemon/catalog.js +48 -0
  25. package/build/devserver/daemon/cron.d.ts +4 -0
  26. package/build/devserver/daemon/cron.js +50 -0
  27. package/build/devserver/daemon/host.d.ts +37 -0
  28. package/build/devserver/daemon/host.js +94 -0
  29. package/build/devserver/daemon/index.d.ts +34 -0
  30. package/build/devserver/daemon/index.js +241 -0
  31. package/build/devserver/db/catalog.d.ts +2 -1
  32. package/build/devserver/db/catalog.js +44 -44
  33. package/build/devserver/db/database.d.ts +27 -11
  34. package/build/devserver/db/database.js +539 -169
  35. package/build/devserver/db/index.d.ts +1 -1
  36. package/build/devserver/db/index.js +1 -1
  37. package/build/devserver/db/routeKinds.d.ts +8 -0
  38. package/build/devserver/db/routeKinds.js +139 -0
  39. package/build/devserver/db/types.d.ts +64 -1
  40. package/build/devserver/db/types.js +33 -1
  41. package/build/devserver/index.d.ts +10 -0
  42. package/build/devserver/index.js +7 -0
  43. package/build/devserver/mstore/store.d.ts +18 -0
  44. package/build/devserver/mstore/store.js +82 -0
  45. package/build/devserver/runtime/host.d.ts +6 -0
  46. package/build/devserver/runtime/host.js +45 -1
  47. package/build/devserver/runtime/module.d.ts +1 -0
  48. package/build/devserver/runtime/module.js +27 -1
  49. package/build/devserver/server.d.ts +6 -0
  50. package/build/devserver/server.js +59 -0
  51. package/build/devserver/ssr.d.ts +25 -0
  52. package/build/devserver/ssr.js +114 -0
  53. package/build/devserver/wasm/sections.d.ts +2 -0
  54. package/build/devserver/wasm/sections.js +42 -0
  55. package/build/devserver/wasm/surface.d.ts +18 -0
  56. package/build/devserver/wasm/surface.js +41 -0
  57. package/docs/README.md +4 -4
  58. package/docs/auth-todo.md +6 -6
  59. package/docs/caching.md +5 -5
  60. package/docs/cli.md +15 -0
  61. package/docs/client.md +40 -0
  62. package/docs/crypto.md +4 -4
  63. package/docs/data.md +6 -6
  64. package/docs/email.md +28 -28
  65. package/docs/environment.md +10 -10
  66. package/docs/index.md +26 -0
  67. package/docs/ratelimit.md +10 -10
  68. package/docs/routing.md +2 -2
  69. package/docs/server.md +61 -0
  70. package/docs/ssr.md +561 -113
  71. package/docs/styling.md +22 -0
  72. package/docs/time.md +1 -1
  73. package/eslint.config.js +10 -1
  74. package/examples/basic/client/components/Header.tsx +3 -0
  75. package/examples/basic/client/routes/features/actions.tsx +0 -2
  76. package/examples/basic/client/routes/hello.tsx +89 -19
  77. package/examples/basic/client/styles/main.css +48 -0
  78. package/examples/basic/server/SsrHelloRender.ts +97 -0
  79. package/examples/basic/server/main.ts +5 -0
  80. package/examples/basic/server/streams/Echo.ts +49 -0
  81. package/package.json +12 -10
  82. package/scripts/gen-toil-docs.mjs +96 -0
  83. package/src/cli/create.ts +2 -2
  84. package/src/client/index.ts +1 -1
  85. package/src/client/routing/mount.tsx +19 -31
  86. package/src/client/ssr/markers.tsx +33 -4
  87. package/src/compiler/config.ts +88 -2
  88. package/src/compiler/docs.ts +47 -308
  89. package/src/compiler/index.ts +236 -32
  90. package/src/compiler/ssr-codegen.ts +1 -1
  91. package/src/compiler/template-build.ts +271 -53
  92. package/src/compiler/toil-docs.generated.ts +26 -0
  93. package/src/devserver/daemon/catalog.ts +120 -0
  94. package/src/devserver/daemon/cron.ts +87 -0
  95. package/src/devserver/daemon/host.ts +224 -0
  96. package/src/devserver/daemon/index.ts +349 -0
  97. package/src/devserver/db/catalog.ts +61 -53
  98. package/src/devserver/db/database.ts +613 -149
  99. package/src/devserver/db/index.ts +1 -1
  100. package/src/devserver/db/routeKinds.ts +147 -0
  101. package/src/devserver/db/types.ts +65 -2
  102. package/src/devserver/index.ts +12 -0
  103. package/src/devserver/mstore/store.ts +121 -0
  104. package/src/devserver/runtime/host.ts +92 -1
  105. package/src/devserver/runtime/module.ts +35 -1
  106. package/src/devserver/server.ts +101 -0
  107. package/src/devserver/ssr.ts +166 -0
  108. package/src/devserver/wasm/sections.ts +59 -0
  109. package/src/devserver/wasm/surface.ts +88 -0
  110. package/test/daemon-build.test.ts +198 -0
  111. package/test/daemon-catalog.test.ts +265 -0
  112. package/test/daemon-emulation.test.ts +216 -0
  113. package/test/devserver-database.test.ts +396 -5
  114. package/test/email-preview.test.ts +6 -1
  115. package/test/fixtures/daemon-app.ts +56 -0
  116. package/test/global-setup.ts +17 -0
  117. package/test/ssr-hydration.test.tsx +107 -0
  118. package/test/ssr-render.test.ts +96 -27
  119. package/test/ssr-template.test.tsx +47 -2
  120. package/vitest.config.ts +3 -0
package/docs/ssr.md CHANGED
@@ -1,184 +1,632 @@
1
- # SSR templates
1
+ # Server-side rendering (SSR)
2
2
 
3
- toiljs renders pages with a split that keeps the wasm guest tiny and the output
4
- coherent with the client bundle: the **template** (the HTML shell, with named
5
- holes) is precompiled and held by the edge, and the guest's `render` entrypoint
6
- returns only the **hole values** — text, attributes, repeated rows, headers,
7
- status. The edge splices the values into the template. A 32-byte template hash
8
- travels with the values so the edge can reject a guest/template skew.
3
+ toiljs server-renders a route by splitting it into two halves that the build
4
+ keeps coherent:
9
5
 
10
- This is for server-rendered HTML. JSON/binary API endpoints use
11
- [Routing](./routing.md) instead.
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
12
 
13
- ## Authoring a route
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.
14
15
 
15
- Opt a route in with `export const ssr = true`. toiljs renders it ONCE at build
16
- into the template (the static HTML shell, holes removed) and generates its typed
17
- `Slot` module; at request time the server fills only the holes and the browser
18
- hydrates the result in place, so an SSR route is served about as fast as a static
19
- file while still delivering real first-paint HTML and SEO.
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.
20
22
 
21
- Mark the dynamic bits of the page with the hole markers from `toiljs/client`.
22
- They are transparent in the browser (they just render their children), so the
23
- same component is your normal client UI; only the build and the server treat them
24
- specially.
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
+ ---
25
33
 
26
- - `<Hole id="name">{value}</Hole>`, a text hole (HTML-escaped for you).
27
- - `<RawHtml id="bio" html={s} />`, a raw-HTML block (you own sanitisation, like
28
- `dangerouslySetInnerHTML`).
29
- - `<Repeat id="rows" each={items}>{(item) => <li>...</li>}</Repeat>`, a repeated
30
- region; the row markup is captured once and stamped per item.
31
- - `<Island>{...}</Island>`, a client-only escape hatch: empty in the server HTML,
32
- rendered after hydration (no first paint or SEO). Put router-hook-driven or
33
- otherwise non-server-safe content here.
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.
34
48
 
35
49
  ```tsx
36
- import { Hole, Repeat, RawHtml, useLoaderData } from 'toiljs/client';
50
+ import { Hole, Island, RawHtml, Repeat, useLoaderData } from 'toiljs/client';
37
51
 
38
52
  export const ssr = true;
39
- export const loader = ({ params }: Toil.LoaderArgs) => loadProfile(params.name);
40
53
 
41
- export default function Profile() {
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() {
42
61
  const d = useLoaderData<typeof loader>();
43
62
  return (
44
- <main>
45
- <h1>@<Hole id="username">{d.username}</Hole></h1>
46
- <RawHtml id="bio" html={d.bioHtml} />
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
+
47
68
  <ul>
48
- <Repeat id="posts" each={d.posts}>
49
- {(p) => <li><Hole id="title">{p.title}</Hole></li>}
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
+ )}
50
76
  </Repeat>
51
77
  </ul>
52
- </main>
78
+
79
+ <Island>
80
+ <p>Hydrated at {new Date().toLocaleTimeString()}.</p>
81
+ </Island>
82
+ </section>
53
83
  );
54
84
  }
55
85
  ```
56
86
 
57
- The build derives each hole's `Slot` id and the template `HASH` from this render,
58
- emits the template manifest the edge serves, and the matching `Slot` module you
59
- import in the server `render` below.
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.
60
141
 
61
- ## The `render` entrypoint
142
+ ---
62
143
 
63
- `render(req_ofs, req_len) -> i64` is a wasm export (re-exported by your `main.ts`
64
- via `export * from 'toiljs/server/runtime/exports'`). It decodes the request,
65
- runs the SSR router to find a matching `render` function, serializes that
66
- function's `SlotValues` into the values envelope, and returns it packed as
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
67
151
  `(ptr << 32) | len`.
68
152
 
69
- You register render functions with the `Ssr` router (the compiler injects the
70
- registration for auto-discovered routes):
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.
71
155
 
72
156
  ```ts
73
- import { Ssr, SlotValues, HtmlBuilder } from 'toiljs/server/runtime';
74
- import { Slot, HASH } from './greeting.slots';
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;
75
165
 
76
- function renderGreeting(req: Request): SlotValues | null {
77
- if (req.path != '/hello') return null; // not my route
78
166
  const v = new SlotValues(HASH);
79
- v.setText(Slot.greeting, 'world & <friends>'); // React-escaped
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
+
80
180
  return v;
81
181
  }
82
182
 
83
- Ssr.register(renderGreeting);
183
+ Ssr.register(renderHello); // side-effect registration
84
184
  ```
85
185
 
86
- A render function returns `SlotValues` for a page it owns, or `null` to let the
87
- next registered renderer try.
186
+ ### Registration is manual; the import is load-bearing
88
187
 
89
- ## Slots
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.
90
191
 
91
- A route's template defines its holes as a small typed module: a `Slot` enum of
92
- stable numeric ids and a 32-byte `HASH` that pins the values to a specific
93
- compiled template.
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.
94
198
 
95
- ```ts
96
- // greeting.slots.ts
97
- export enum Slot { greeting = 0, count = 1 }
98
- export const HASH: StaticArray<u8> = /* 32 bytes, generated from the template */;
99
- ```
199
+ ### What `render` does for a request the router misses
100
200
 
101
- These are generated by the compiler from your rendered template, and are
102
- hand-writable through the typed API.
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.
103
206
 
104
- ## `SlotValues`
207
+ ---
105
208
 
106
- The object a render function fills. Each setter targets a slot id; the kind
107
- determines how the edge escapes and splices it.
209
+ ## 3. Reference: `SlotValues`
108
210
 
109
- | Method | Signature | Use |
110
- | --- | --- | --- |
111
- | `setText` | `setText(slotId, value: string)` | Text content. React-escaped (safe by default). |
112
- | `setRaw` | `setRaw(slotId, html: string)` | Raw HTML. You are responsible for sanitizing it. |
113
- | `setAttr` | `setAttr(slotId, value: string)` | An attribute value (attribute-escaped). |
114
- | `setRepeat` | `setRepeat(slotId, rows: HtmlBuilder)` | A repeated region, pre-stamped row by row. |
115
- | `setHeader` | `setHeader(name, value)` | A response header. |
116
- | `setStatus` | `setStatus(code)` | The HTTP status. |
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) |
117
223
 
118
- Construct it with the route's template hash: `new SlotValues(HASH)`.
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.
119
227
 
120
- ## `HtmlBuilder`
228
+ ### `HtmlBuilder`
121
229
 
122
230
  Assembles a repeat region (or any HTML fragment) with the same escaping
123
- guarantees, chaining `raw` (verbatim markup) and `text` (escaped content):
231
+ guarantees. Chain `raw` (verbatim template bytes) and `text` / `attr`
232
+ (React-escaped values):
124
233
 
125
234
  ```ts
126
235
  const rows = new HtmlBuilder();
127
236
  for (let i = 0; i < items.length; i++) {
128
237
  rows.raw('<li>').text(items[i]).raw('</li>'); // items[i] is escaped
129
238
  }
130
- v.setRepeat(Slot.count, rows);
239
+ v.setRepeat(Slot.list, rows);
131
240
  ```
132
241
 
133
- ## Escaping
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
+ ---
134
255
 
135
- `setText`, `setAttr`, and `HtmlBuilder.text` escape exactly as React does, so
136
- server-rendered markup and client hydration agree:
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:
137
261
 
138
262
  ```
139
- " → &quot; & → &amp; ' → &#x27;
140
- < → &lt; > → &gt;
263
+ " → &quot; & → &amp; ' → &#x27;
264
+ < → &lt; > → &gt;
141
265
  ```
142
266
 
143
- (`'` becomes `&#x27;` React's exact choice not `&#39;`.) Use `setRaw` /
144
- `HtmlBuilder.raw` only for markup you have produced or sanitized yourself.
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
+ ---
145
279
 
146
- ## Hydration and SSR-safe routes
280
+ ## 5. The build flow and generated artifacts
147
281
 
148
- The browser hydrates the server HTML in place rather than re-rendering it: the
149
- markup the edge splices is byte-for-byte what React would produce for the same
150
- data (the holes are escaped exactly as React escapes them, above), so
151
- `hydrateRoot` matches with no flash and no client re-render.
282
+ `extractTemplates` (driven by `toiljs build`) does, for each `ssr = true` route:
152
283
 
153
- For that to hold, an SSR route and the layouts above it must render under static
154
- markup: use the hole markers and `useLoaderData`, and move anything that needs
155
- router hooks or browser-only APIs into an `<Island>`. A route that cannot render
156
- this way is skipped at build (with a warning) and falls back to normal client
157
- rendering, so opting in is always safe.
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.
158
294
 
159
- Build output for an SSR route lands in `build/client/_ssr/` (the template and its
160
- binary manifest) alongside the generated `Slot` module; routes without
161
- `ssr = true` are untouched.
295
+ ### Where the artifacts land
162
296
 
163
- ## The values envelope
297
+ For a route named `<name>` (see below), under `build/client/_ssr/`:
164
298
 
165
- For reference, the guest serializes `SlotValues` to this little-endian layout
166
- (the edge decodes it and splices against the template):
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):
167
365
 
168
366
  ```
169
367
  u16 status
170
368
  [32] template_hash
171
- u16 n_headers ; then for each: u16 name_len, u16 val_len, name, val
172
- u16 n_slots ; then for each: u16 slot_id, u8 kind, u32 value_len, value
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
+ ];
173
491
  ```
174
492
 
175
- If a value cannot be represented (an overflow), the guest emits a fail-safe 500
176
- rather than a corrupt page. The `template_hash` lets the edge detect and reject a
177
- mismatch between the guest's idea of the template and the deployed one.
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.)
178
496
 
179
- ## Dev server
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
+ ```
180
585
 
181
- The dev server provides the same `render` path as the edge: `WasmServerModule`
182
- has a `dispatchRender` that runs a fresh instance through the `render` export and
183
- returns the raw values envelope, mirroring the edge contract, so SSR behaves the
184
- same locally.
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>