toiljs 0.0.60 → 0.0.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +31 -0
- package/CHANGELOG.md +5 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2 -2
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +1 -1
- package/build/client/routing/mount.js +12 -1
- package/build/client/ssr/markers.d.ts +1 -0
- package/build/client/ssr/markers.js +3 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +21 -0
- package/build/compiler/config.js +35 -0
- package/build/compiler/docs.d.ts +2 -1
- package/build/compiler/docs.js +33 -304
- package/build/compiler/index.d.ts +13 -0
- package/build/compiler/index.js +113 -21
- package/build/compiler/template-build.d.ts +21 -1
- package/build/compiler/template-build.js +110 -26
- package/build/compiler/toil-docs.generated.d.ts +1 -0
- package/build/compiler/toil-docs.generated.js +20 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/daemon/catalog.d.ts +26 -0
- package/build/devserver/daemon/catalog.js +48 -0
- package/build/devserver/daemon/cron.d.ts +4 -0
- package/build/devserver/daemon/cron.js +50 -0
- package/build/devserver/daemon/host.d.ts +37 -0
- package/build/devserver/daemon/host.js +94 -0
- package/build/devserver/daemon/index.d.ts +34 -0
- package/build/devserver/daemon/index.js +241 -0
- package/build/devserver/db/catalog.d.ts +2 -1
- package/build/devserver/db/catalog.js +44 -44
- package/build/devserver/db/database.d.ts +27 -11
- package/build/devserver/db/database.js +539 -169
- package/build/devserver/db/index.d.ts +1 -1
- package/build/devserver/db/index.js +1 -1
- package/build/devserver/db/routeKinds.d.ts +8 -0
- package/build/devserver/db/routeKinds.js +139 -0
- package/build/devserver/db/types.d.ts +64 -1
- package/build/devserver/db/types.js +33 -1
- package/build/devserver/index.d.ts +10 -0
- package/build/devserver/index.js +7 -0
- package/build/devserver/mstore/store.d.ts +18 -0
- package/build/devserver/mstore/store.js +82 -0
- package/build/devserver/runtime/host.d.ts +6 -0
- package/build/devserver/runtime/host.js +45 -1
- package/build/devserver/runtime/module.d.ts +1 -0
- package/build/devserver/runtime/module.js +27 -1
- package/build/devserver/server.d.ts +6 -0
- package/build/devserver/server.js +59 -0
- package/build/devserver/ssr.d.ts +25 -0
- package/build/devserver/ssr.js +114 -0
- package/build/devserver/wasm/sections.d.ts +2 -0
- package/build/devserver/wasm/sections.js +42 -0
- package/build/devserver/wasm/surface.d.ts +18 -0
- package/build/devserver/wasm/surface.js +41 -0
- package/docs/README.md +4 -4
- package/docs/auth-todo.md +6 -6
- package/docs/caching.md +5 -5
- package/docs/cli.md +15 -0
- package/docs/client.md +40 -0
- package/docs/crypto.md +4 -4
- package/docs/data.md +6 -6
- package/docs/email.md +28 -28
- package/docs/environment.md +10 -10
- package/docs/index.md +26 -0
- package/docs/ratelimit.md +10 -10
- package/docs/routing.md +2 -2
- package/docs/server.md +61 -0
- package/docs/ssr.md +561 -113
- package/docs/styling.md +22 -0
- package/docs/time.md +1 -1
- package/eslint.config.js +10 -1
- package/examples/basic/client/components/Header.tsx +3 -0
- package/examples/basic/client/routes/features/actions.tsx +0 -2
- package/examples/basic/client/routes/hello.tsx +89 -19
- package/examples/basic/client/styles/main.css +48 -0
- package/examples/basic/server/SsrHelloRender.ts +97 -0
- package/examples/basic/server/main.ts +5 -0
- package/examples/basic/server/streams/Echo.ts +49 -0
- package/package.json +12 -10
- package/scripts/gen-toil-docs.mjs +96 -0
- package/src/cli/create.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/routing/mount.tsx +18 -2
- package/src/client/ssr/markers.tsx +22 -0
- package/src/compiler/config.ts +88 -2
- package/src/compiler/docs.ts +47 -308
- package/src/compiler/index.ts +236 -32
- package/src/compiler/ssr-codegen.ts +1 -1
- package/src/compiler/template-build.ts +247 -46
- package/src/compiler/toil-docs.generated.ts +26 -0
- package/src/devserver/daemon/catalog.ts +120 -0
- package/src/devserver/daemon/cron.ts +87 -0
- package/src/devserver/daemon/host.ts +224 -0
- package/src/devserver/daemon/index.ts +349 -0
- package/src/devserver/db/catalog.ts +61 -53
- package/src/devserver/db/database.ts +613 -149
- package/src/devserver/db/index.ts +1 -1
- package/src/devserver/db/routeKinds.ts +147 -0
- package/src/devserver/db/types.ts +65 -2
- package/src/devserver/index.ts +12 -0
- package/src/devserver/mstore/store.ts +121 -0
- package/src/devserver/runtime/host.ts +92 -1
- package/src/devserver/runtime/module.ts +35 -1
- package/src/devserver/server.ts +101 -0
- package/src/devserver/ssr.ts +166 -0
- package/src/devserver/wasm/sections.ts +59 -0
- package/src/devserver/wasm/surface.ts +88 -0
- package/test/daemon-build.test.ts +198 -0
- package/test/daemon-catalog.test.ts +265 -0
- package/test/daemon-emulation.test.ts +216 -0
- package/test/devserver-database.test.ts +396 -5
- package/test/email-preview.test.ts +6 -1
- package/test/fixtures/daemon-app.ts +56 -0
- package/test/global-setup.ts +17 -0
- package/test/ssr-render.test.ts +94 -27
- package/test/ssr-template.test.tsx +44 -1
- package/vitest.config.ts +3 -0
package/docs/ssr.md
CHANGED
|
@@ -1,184 +1,632 @@
|
|
|
1
|
-
# SSR
|
|
1
|
+
# Server-side rendering (SSR)
|
|
2
2
|
|
|
3
|
-
toiljs renders
|
|
4
|
-
coherent
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
34
48
|
|
|
35
49
|
```tsx
|
|
36
|
-
import { Hole,
|
|
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
|
|
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
|
-
<
|
|
45
|
-
<h1
|
|
46
|
-
|
|
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="
|
|
49
|
-
{(
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
142
|
+
---
|
|
62
143
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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 {
|
|
74
|
-
import {
|
|
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
|
-
|
|
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(
|
|
183
|
+
Ssr.register(renderHello); // side-effect registration
|
|
84
184
|
```
|
|
85
185
|
|
|
86
|
-
|
|
87
|
-
next registered renderer try.
|
|
186
|
+
### Registration is manual; the import is load-bearing
|
|
88
187
|
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
207
|
+
---
|
|
105
208
|
|
|
106
|
-
|
|
107
|
-
determines how the edge escapes and splices it.
|
|
209
|
+
## 3. Reference: `SlotValues`
|
|
108
210
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
|
114
|
-
|
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
228
|
+
### `HtmlBuilder`
|
|
121
229
|
|
|
122
230
|
Assembles a repeat region (or any HTML fragment) with the same escaping
|
|
123
|
-
guarantees
|
|
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.
|
|
239
|
+
v.setRepeat(Slot.list, rows);
|
|
131
240
|
```
|
|
132
241
|
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
" → "
|
|
140
|
-
< → <
|
|
263
|
+
" → " & → & ' → '
|
|
264
|
+
< → < > → >
|
|
141
265
|
```
|
|
142
266
|
|
|
143
|
-
|
|
144
|
-
|
|
267
|
+
The detail that bites: `'` becomes `'` (React's exact choice), **not**
|
|
268
|
+
`'`. 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
|
-
##
|
|
280
|
+
## 5. The build flow and generated artifacts
|
|
147
281
|
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
binary manifest) alongside the generated `Slot` module; routes without
|
|
161
|
-
`ssr = true` are untouched.
|
|
295
|
+
### Where the artifacts land
|
|
162
296
|
|
|
163
|
-
|
|
297
|
+
For a route named `<name>` (see below), under `build/client/_ssr/`:
|
|
164
298
|
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 `'` instead of `'`, 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>
|