toiljs 0.0.33 → 0.0.36
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/CHANGELOG.md +19 -0
- package/README.md +1 -0
- package/as-pect.config.js +8 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +124 -7
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +42 -0
- package/build/client/auth.js +179 -0
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +3 -1
- package/build/client/routing/loader.d.ts +1 -0
- package/build/client/routing/loader.js +37 -0
- package/build/client/routing/mount.js +32 -1
- package/build/client/ssr/markers.d.ts +34 -0
- package/build/client/ssr/markers.js +49 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +88 -1
- package/build/compiler/generate.d.ts +2 -0
- package/build/compiler/generate.js +2 -2
- package/build/compiler/index.js +2 -0
- package/build/compiler/ssr-codegen.d.ts +2 -0
- package/build/compiler/ssr-codegen.js +36 -0
- package/build/compiler/template-build.d.ts +29 -0
- package/build/compiler/template-build.js +150 -0
- package/build/compiler/template.d.ts +22 -0
- package/build/compiler/template.js +169 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/cache.d.ts +8 -0
- package/build/devserver/cache.js +0 -0
- package/build/devserver/crypto.js +15 -0
- package/build/devserver/host.js +1 -0
- package/build/devserver/index.js +10 -1
- package/build/devserver/module.d.ts +1 -0
- package/build/devserver/module.js +23 -1
- package/docs/README.md +56 -0
- package/docs/auth.md +261 -0
- package/docs/caching.md +115 -0
- package/docs/cookies.md +457 -0
- package/docs/crypto.md +130 -0
- package/docs/data.md +131 -0
- package/docs/getting-started.md +128 -0
- package/docs/routing.md +259 -0
- package/docs/rpc.md +149 -0
- package/docs/ssr.md +184 -0
- package/docs/time.md +43 -0
- package/examples/basic/client/routes/auth.tsx +198 -0
- package/examples/basic/client/routes/cookies.tsx +199 -0
- package/examples/basic/client/routes/features/index.tsx +34 -10
- package/examples/basic/client/routes/hello.tsx +43 -0
- package/examples/basic/client/routes/pq.tsx +135 -0
- package/examples/basic/server/AuthTestHandler.ts +15 -0
- package/examples/basic/server/AuthVerifyHandler.ts +23 -0
- package/examples/basic/server/CacheHandler.ts +25 -0
- package/examples/basic/server/DecoCache.ts +18 -0
- package/examples/basic/server/FastTrapHandler.ts +8 -0
- package/examples/basic/server/README.md +19 -0
- package/examples/basic/server/SpinHandler.ts +18 -0
- package/examples/basic/server/SsrGreetingRender.ts +27 -0
- package/examples/basic/server/authexample-main.ts +8 -0
- package/examples/basic/server/authtest-main.ts +8 -0
- package/examples/basic/server/authverify-main.ts +8 -0
- package/examples/basic/server/cache-main.ts +8 -0
- package/examples/basic/server/core/AppHandler.ts +290 -0
- package/examples/basic/server/core/store.ts +31 -0
- package/examples/basic/server/deco-main.ts +18 -0
- package/examples/basic/server/main.ts +13 -2
- package/examples/basic/server/models/NewPlayer.ts +5 -0
- package/examples/basic/server/models/Player.ts +8 -0
- package/examples/basic/server/models/ScoreDelta.ts +5 -0
- package/examples/basic/server/models/Standings.ts +7 -0
- package/examples/basic/server/routes/Auth.ts +184 -0
- package/examples/basic/server/routes/Leaderboard.ts +20 -0
- package/examples/basic/server/routes/Players.ts +53 -0
- package/examples/basic/server/routes/PqDemo.ts +109 -0
- package/examples/basic/server/routes/Session.ts +73 -0
- package/examples/basic/server/scheduled/README.md +7 -0
- package/examples/basic/server/services/Stats.ts +11 -0
- package/examples/basic/server/services/remotes.ts +7 -0
- package/examples/basic/server/spin-main.ts +13 -0
- package/examples/basic/server/ssr/greeting.slots.ts +19 -0
- package/examples/basic/server/ssr-main.ts +18 -0
- package/examples/basic/server/toil-server-env.d.ts +94 -0
- package/examples/basic/server/trap-main.ts +8 -0
- package/package.json +5 -3
- package/server/globals/auth.ts +281 -0
- package/server/runtime/README.md +61 -0
- package/server/runtime/env/Server.ts +12 -0
- package/server/runtime/exports/index.ts +17 -0
- package/server/runtime/exports/render.ts +51 -0
- package/server/runtime/http/base64.ts +104 -0
- package/server/runtime/http/cookie.ts +416 -0
- package/server/runtime/http/cookies.ts +197 -0
- package/server/runtime/http/date.ts +72 -0
- package/server/runtime/http/percent.ts +76 -0
- package/server/runtime/http/securecookies.ts +224 -0
- package/server/runtime/index.ts +17 -0
- package/server/runtime/request.ts +24 -0
- package/server/runtime/response.ts +85 -0
- package/server/runtime/ssr/Ssr.ts +43 -0
- package/server/runtime/ssr/encode.ts +110 -0
- package/server/runtime/ssr/escape.ts +83 -0
- package/server/runtime/ssr/slots.ts +144 -0
- package/server/runtime/time.ts +29 -0
- package/src/cli/create.ts +159 -14
- package/src/client/auth.ts +322 -0
- package/src/client/index.ts +5 -1
- package/src/client/routing/loader.ts +56 -0
- package/src/client/routing/mount.tsx +37 -1
- package/src/client/ssr/markers.tsx +140 -0
- package/src/compiler/docs.ts +88 -1
- package/src/compiler/generate.ts +2 -2
- package/src/compiler/index.ts +5 -0
- package/src/compiler/ssr-codegen.ts +85 -0
- package/src/compiler/template-build.ts +275 -0
- package/src/compiler/template.ts +265 -0
- package/src/devserver/cache.ts +0 -0
- package/src/devserver/crypto.ts +23 -0
- package/src/devserver/host.ts +4 -0
- package/src/devserver/index.ts +21 -1
- package/src/devserver/module.ts +39 -1
- package/test/assembly/cookie.spec.ts +302 -0
- package/test/assembly/example.spec.ts +5 -1
- package/test/assembly/ssr.spec.ts +94 -0
- package/test/devserver.test.ts +48 -4
- package/test/fixtures/bignum-wire/spec.ts +27 -0
- package/test/rpc-bignum-wire.test.ts +164 -0
- package/test/ssr-render.test.ts +128 -0
- package/test/ssr-template.test.tsx +348 -0
- package/examples/basic/server/HelloHandler.ts +0 -42
- package/examples/basic/server/api.ts +0 -137
package/src/compiler/docs.ts
CHANGED
|
@@ -21,6 +21,7 @@ the conventions you must follow:
|
|
|
21
21
|
- \`.toil/docs/client.md\`, the \`Toil\` global, Link / NavLink, router hooks
|
|
22
22
|
- \`.toil/docs/styling.md\`, CSS / Sass / Less / Stylus / Tailwind (via \`toiljs configure\`)
|
|
23
23
|
- \`.toil/docs/server.md\`, the toilscript server target
|
|
24
|
+
- \`.toil/docs/ssr.md\`, server-side rendering (\`ssr = true\`, hole markers, the server \`render\`)
|
|
24
25
|
- \`.toil/docs/cli.md\`, toiljs CLI commands
|
|
25
26
|
|
|
26
27
|
\`.toil/docs/\` is regenerated by toiljs; do not edit it by hand. This pointer file is yours to edit.
|
|
@@ -99,7 +100,7 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
99
100
|
'- Scripts: `npm run dev` (HMR), `npm run build` (→ `build/client` + `build/server`),',
|
|
100
101
|
' `npm start` (self-host the build).',
|
|
101
102
|
'',
|
|
102
|
-
'See `routing.md`, `client.md`, `styling.md`, `server.md`, `cli.md`.',
|
|
103
|
+
'See `routing.md`, `client.md`, `styling.md`, `server.md`, `ssr.md`, `cli.md`.',
|
|
103
104
|
]),
|
|
104
105
|
'routing.md': doc([
|
|
105
106
|
'# Routing',
|
|
@@ -136,6 +137,11 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
136
137
|
'',
|
|
137
138
|
'`Toil.useRouter().refresh()` re-runs loaders.',
|
|
138
139
|
'',
|
|
140
|
+
'## Server rendering',
|
|
141
|
+
'',
|
|
142
|
+
'Add `export const ssr = true` to render a route on the server (real first-paint HTML + SEO,',
|
|
143
|
+
'then hydration) with near-static speed. See `ssr.md`.',
|
|
144
|
+
'',
|
|
139
145
|
'## Navigation',
|
|
140
146
|
'',
|
|
141
147
|
'- `Toil.Link` / `Toil.NavLink` (adds an active class) / `Toil.useRouter()`',
|
|
@@ -263,6 +269,87 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
263
269
|
'For a REST-only project, `Server.handler = () => new RestHandler()` does the same with no',
|
|
264
270
|
'boilerplate. On the client: `Server.REST.todos.getTodo({ params: { id } })` (see client.md).',
|
|
265
271
|
]),
|
|
272
|
+
'ssr.md': doc([
|
|
273
|
+
'# Server-side rendering (SSR)',
|
|
274
|
+
'',
|
|
275
|
+
'Opt a route into SSR with `export const ssr = true`. toiljs renders the page ONCE at build',
|
|
276
|
+
'time into a template with holes; at request time the server fills only the dynamic holes and',
|
|
277
|
+
'serves the page, then the browser hydrates it in place. The page is never re-rendered per',
|
|
278
|
+
'request, so an SSR route is served about as fast as a static file while still delivering real',
|
|
279
|
+
'first-paint HTML and SEO.',
|
|
280
|
+
'',
|
|
281
|
+
'## Marking the holes',
|
|
282
|
+
'',
|
|
283
|
+
'Wrap the dynamic bits of the page in hole markers from `toiljs/client`. They are transparent',
|
|
284
|
+
'in the browser (they just render their children); only the build and the server treat them',
|
|
285
|
+
'specially, so the same component is your normal client UI.',
|
|
286
|
+
'',
|
|
287
|
+
'- `<Hole id="name">{value}</Hole>`, a text hole (the value is HTML-escaped for you).',
|
|
288
|
+
'- `<RawHtml id="bio" html={s} />`, a raw-HTML block (you own sanitisation, like',
|
|
289
|
+
' `dangerouslySetInnerHTML`).',
|
|
290
|
+
'- `<Repeat id="rows" each={items}>{(item) => <li>...</li>}</Repeat>`, a repeated region (a',
|
|
291
|
+
' list); the row markup is captured once and stamped per item.',
|
|
292
|
+
'- `<Island>{...}</Island>`, a client-only escape hatch: empty in the server HTML, rendered',
|
|
293
|
+
' after hydration (so it gets no first paint or SEO). Put router-hook-driven or otherwise',
|
|
294
|
+
' non-server-safe bits here.',
|
|
295
|
+
'',
|
|
296
|
+
' import { Hole, Repeat, RawHtml, useLoaderData } from "toiljs/client";',
|
|
297
|
+
' export const ssr = true;',
|
|
298
|
+
' export const loader = ({ params }: Toil.LoaderArgs) => loadProfile(params.name);',
|
|
299
|
+
' export default function Profile() {',
|
|
300
|
+
' const d = useLoaderData<typeof loader>();',
|
|
301
|
+
' return (',
|
|
302
|
+
' <main>',
|
|
303
|
+
' <h1>@<Hole id="username">{d.username}</Hole></h1>',
|
|
304
|
+
' <RawHtml id="bio" html={d.bioHtml} />',
|
|
305
|
+
' <ul>',
|
|
306
|
+
' <Repeat id="posts" each={d.posts}>',
|
|
307
|
+
' {(p) => <li><Hole id="title">{p.title}</Hole></li>}',
|
|
308
|
+
' </Repeat>',
|
|
309
|
+
' </ul>',
|
|
310
|
+
' </main>',
|
|
311
|
+
' );',
|
|
312
|
+
' }',
|
|
313
|
+
'',
|
|
314
|
+
'## The server render',
|
|
315
|
+
'',
|
|
316
|
+
'For each SSR route the build emits a typed `Slot` enum and a coherence hash (a',
|
|
317
|
+
'`<route>.slots.ts` module). In the server you write a `render(req)` that fills each slot from',
|
|
318
|
+
'the request and data, using the `SlotValues` API, and register it with `Ssr`:',
|
|
319
|
+
'',
|
|
320
|
+
'```ts',
|
|
321
|
+
'import { Request, SlotValues, HtmlBuilder, Ssr } from "toiljs/server/runtime";',
|
|
322
|
+
'import { Slot, HASH } from "./profile.slots";',
|
|
323
|
+
'',
|
|
324
|
+
'function renderProfile(req: Request): SlotValues | null {',
|
|
325
|
+
' if (!req.path.startsWith("/u/")) return null; // not this route',
|
|
326
|
+
' const v = new SlotValues(HASH);',
|
|
327
|
+
' v.setText(Slot.username, usernameFor(req)); // escaped',
|
|
328
|
+
' v.setRaw(Slot.bio, bioHtml); // verbatim',
|
|
329
|
+
' const rows = new HtmlBuilder();',
|
|
330
|
+
' for (let i = 0; i < posts.length; i++) rows.raw("<li>").text(posts[i]).raw("</li>");',
|
|
331
|
+
' v.setRepeat(Slot.posts, rows); // stamped rows',
|
|
332
|
+
' return v;',
|
|
333
|
+
'}',
|
|
334
|
+
'Ssr.register(renderProfile);',
|
|
335
|
+
'```',
|
|
336
|
+
'',
|
|
337
|
+
'`SlotValues`: `setText` (escaped), `setRaw` (verbatim), `setAttr` (attribute value),',
|
|
338
|
+
'`setRepeat` (a stamped `HtmlBuilder`), plus `setHeader` and `setStatus`. The hole values you',
|
|
339
|
+
'return are filled into the template and the page is served; the browser then hydrates against',
|
|
340
|
+
'the same data, so the markup matches with no client re-render.',
|
|
341
|
+
'',
|
|
342
|
+
'## Rules of thumb',
|
|
343
|
+
'',
|
|
344
|
+
'- An SSR route (and the layouts above it) must render under static markup. Use the hole',
|
|
345
|
+
' markers and `useLoaderData`; move anything that needs router hooks or browser-only APIs into',
|
|
346
|
+
' an `<Island>`. A route that cannot render this way is skipped at build (with a warning) and',
|
|
347
|
+
' simply falls back to normal client rendering.',
|
|
348
|
+
'- Hole values are HTML-escaped exactly as React escapes them, so hydration is byte-for-byte',
|
|
349
|
+
' clean. Keep a repeat row\'s structure the same across items (only the leaf hole values vary).',
|
|
350
|
+
'- Build output for an SSR route lands in `build/client/_ssr/` (the template + its manifest)',
|
|
351
|
+
' alongside the generated `Slot` module; routes without `ssr = true` are unaffected.',
|
|
352
|
+
]),
|
|
266
353
|
'cli.md': doc([
|
|
267
354
|
'# CLI',
|
|
268
355
|
'',
|
package/src/compiler/generate.ts
CHANGED
|
@@ -90,7 +90,7 @@ function relFromToil(cfg: ResolvedToilConfig, abs: string): string {
|
|
|
90
90
|
return rel;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function findLayout(cfg: ResolvedToilConfig): string | undefined {
|
|
93
|
+
export function findLayout(cfg: ResolvedToilConfig): string | undefined {
|
|
94
94
|
return ['layout.tsx', 'layout.jsx']
|
|
95
95
|
.map((name) => path.join(cfg.clientAbsDir, name))
|
|
96
96
|
.find((p) => fs.existsSync(p));
|
|
@@ -189,7 +189,7 @@ function specialIn(dir: string, base: string): string | undefined {
|
|
|
189
189
|
* ancestor directory down to the file's own. With `includeClientRoot`, `client/<base>` is prepended
|
|
190
190
|
* as the outermost (used by templates; the root `client/layout.tsx` is instead the top-level layout).
|
|
191
191
|
*/
|
|
192
|
-
function findSpecialChain(
|
|
192
|
+
export function findSpecialChain(
|
|
193
193
|
cfg: ResolvedToilConfig,
|
|
194
194
|
routeFile: string,
|
|
195
195
|
base: string,
|
package/src/compiler/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { RunningBackend } from 'toiljs/backend';
|
|
|
14
14
|
import { loadConfig } from './config.js';
|
|
15
15
|
import { generate } from './generate.js';
|
|
16
16
|
import { prerenderStaticParams } from './ssg.js';
|
|
17
|
+
import { extractTemplates } from './template-build.js';
|
|
17
18
|
import { createViteConfig } from './vite.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -312,6 +313,10 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
|
|
|
312
313
|
await viteBuild(await createViteConfig(cfg));
|
|
313
314
|
// SSG: bake per-URL HTML + sitemap for dynamic routes that opt in via `generateStaticParams`.
|
|
314
315
|
await prerenderStaticParams(cfg);
|
|
316
|
+
// Edge SSR: render `export const ssr = true` routes to template-with-holes
|
|
317
|
+
// (`_ssr/*.tmpl|slots` + the guest `Slot` module), copied into the edge host
|
|
318
|
+
// bundle. No-op when no route opts in.
|
|
319
|
+
await extractTemplates(cfg);
|
|
315
320
|
}
|
|
316
321
|
|
|
317
322
|
/**
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time codegen of the AssemblyScript surface a route's guest `render`
|
|
3
|
+
* uses. From the extracted holes (see `template.ts`) this emits:
|
|
4
|
+
*
|
|
5
|
+
* - a `Slot` enum (hole name -> stable numeric id, the SAME ids the host
|
|
6
|
+
* `.slots` manifest carries), so host and guest agree by construction; and
|
|
7
|
+
* - the coherence `HASH` (32 bytes) compiled into the guest and echoed in
|
|
8
|
+
* every values envelope; the host 500s on a mismatch (deploy skew).
|
|
9
|
+
*
|
|
10
|
+
* The render BODY is authored against `SlotValues` (the typed, hand-writable
|
|
11
|
+
* API). A route's `render(req)` derives its data from the request/edge and
|
|
12
|
+
* fills the slots:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { Request } from 'toiljs/server/runtime';
|
|
16
|
+
* import { SlotValues } from 'toiljs/server/runtime/ssr/slots';
|
|
17
|
+
* import { Slot, HASH } from './u_name.slots';
|
|
18
|
+
*
|
|
19
|
+
* export function renderUName(req: Request): SlotValues {
|
|
20
|
+
* const v = new SlotValues(HASH);
|
|
21
|
+
* v.setText(Slot.username, pathParam(req, 'name'));
|
|
22
|
+
* v.setRaw(Slot.bio, bio);
|
|
23
|
+
* const rows = new HtmlBuilder();
|
|
24
|
+
* for (let i = 0; i < posts.length; i++) rows.raw('<li>').text(posts[i]).raw('</li>');
|
|
25
|
+
* v.setRepeat(Slot.posts, rows);
|
|
26
|
+
* return v;
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Generating that body automatically from the JSX-subset bindings is a later
|
|
31
|
+
* phase; the typed enum + hash here are what make a hand-written or generated
|
|
32
|
+
* body coherent with the deployed template.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { SlotRecord } from './template.js';
|
|
36
|
+
import { assignSlotIds } from './template.js';
|
|
37
|
+
|
|
38
|
+
/** Emit a `StaticArray<u8>` literal of the 32 hash bytes. */
|
|
39
|
+
function hashLiteral(hash: Buffer): string {
|
|
40
|
+
const bytes = Array.from(hash.values())
|
|
41
|
+
.map((b) => `0x${b.toString(16).padStart(2, '0')}`)
|
|
42
|
+
.join(', ');
|
|
43
|
+
return `[${bytes}]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A slot name is a valid AS enum member iff it is a bare identifier; anything
|
|
47
|
+
* else (e.g. a hyphenated id) is rejected so the generated module can't break
|
|
48
|
+
* or smuggle code. */
|
|
49
|
+
function isIdent(name: string): boolean {
|
|
50
|
+
return /^[A-Za-z_$][\w$]*$/.test(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate the `<route>.slots.ts` AssemblyScript module: the `Slot` enum and
|
|
55
|
+
* the `HASH` constant. `slots` is the top-level slot list from
|
|
56
|
+
* `extractFromHtml`; ids are assigned in document order to match the host
|
|
57
|
+
* `.slots` manifest written by `encodeSlots`.
|
|
58
|
+
*/
|
|
59
|
+
export function generateSlotsModule(routeName: string, slots: SlotRecord[], hash: Buffer): string {
|
|
60
|
+
if (hash.length !== 32) throw new Error('toil ssr: coherence hash must be 32 bytes');
|
|
61
|
+
const ids = assignSlotIds(slots);
|
|
62
|
+
const members: string[] = [];
|
|
63
|
+
for (const [name, id] of ids) {
|
|
64
|
+
if (!isIdent(name)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`toil ssr: hole id "${name}" in route "${routeName}" is not a valid identifier ` +
|
|
67
|
+
`(use [A-Za-z_][\\w]*) so it can be a Slot enum member`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
members.push(` ${name} = ${id},`);
|
|
71
|
+
}
|
|
72
|
+
return `// AUTO-GENERATED by toil (edge SSR). Do not edit.
|
|
73
|
+
// Route: ${routeName}. Slot ids match the deployed .slots manifest; HASH is the
|
|
74
|
+
// coherence hash the host checks against the template (deploy-skew guard).
|
|
75
|
+
|
|
76
|
+
/** Stable hole ids for this route's template. */
|
|
77
|
+
export enum Slot {
|
|
78
|
+
${members.join('\n')}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Coherence hash (32 bytes) baked into the guest and echoed in every values
|
|
82
|
+
* envelope; the host rejects a response whose hash != the deployed template. */
|
|
83
|
+
export const HASH: StaticArray<u8> = ${hashLiteral(hash)};
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time orchestration for edge SSR: render each opted-in route to a
|
|
3
|
+
* template-with-holes and emit the artifacts the edge + guest consume.
|
|
4
|
+
*
|
|
5
|
+
* The deterministic core (`extractRouteTemplate`, `injectIntoShell`,
|
|
6
|
+
* `writeTemplateArtifacts`) is unit-tested with controlled components; the
|
|
7
|
+
* `extractTemplates` driver loads real route + layout modules through a short-
|
|
8
|
+
* lived Vite SSR server (the same pattern as `ssg.ts`).
|
|
9
|
+
*
|
|
10
|
+
* A route opts in with `export const ssr = true`. Its page + layout chain are
|
|
11
|
+
* rendered under the loader-data provider with sample data, in sentinel mode;
|
|
12
|
+
* the result is spliced into the built shell's `#root`, stripped to a `.tmpl`,
|
|
13
|
+
* and written alongside the binary `.slots` manifest and the generated AS
|
|
14
|
+
* `Slot` enum + `HASH`. A route (or layout) that throws under static markup
|
|
15
|
+
* (e.g. it uses router hooks outside the supported subset) is skipped with a
|
|
16
|
+
* warning and falls back to pure client rendering.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
createElement,
|
|
24
|
+
type ComponentType,
|
|
25
|
+
type Context,
|
|
26
|
+
type ReactNode,
|
|
27
|
+
} from 'react';
|
|
28
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
29
|
+
import { createServer } from 'vite';
|
|
30
|
+
|
|
31
|
+
import { type ResolvedToilConfig } from './config.js';
|
|
32
|
+
import { findLayout, findSpecialChain } from './generate.js';
|
|
33
|
+
import { scanRoutes } from './routes.js';
|
|
34
|
+
import { generateSlotsModule } from './ssr-codegen.js';
|
|
35
|
+
import {
|
|
36
|
+
assignSlotIds,
|
|
37
|
+
coherenceHash,
|
|
38
|
+
encodeSlots,
|
|
39
|
+
extractFromHtml,
|
|
40
|
+
type Extracted,
|
|
41
|
+
} from './template.js';
|
|
42
|
+
import { createViteConfig } from './vite.js';
|
|
43
|
+
|
|
44
|
+
/** Marker element the client `mount` looks for to switch to `hydrateRoot`. */
|
|
45
|
+
const SSR_MARKER = '<template id="__toil_ssr"></template>';
|
|
46
|
+
const ROOT_DIV = '<div id="root"></div>';
|
|
47
|
+
|
|
48
|
+
export interface RouteRenderInput {
|
|
49
|
+
/** File-safe route name (the `<name>.tmpl` stem). */
|
|
50
|
+
name: string;
|
|
51
|
+
Page: ComponentType;
|
|
52
|
+
/** Layout chain, OUTERMOST first (root layout, then nested). */
|
|
53
|
+
layouts: ComponentType<{ children?: ReactNode }>[];
|
|
54
|
+
/** Sample loader data provided via the loader context during the render. */
|
|
55
|
+
loaderData: unknown;
|
|
56
|
+
/** The client's `LoaderDataContext` (loaded via the SSR module graph so it
|
|
57
|
+
* is the same instance the page's `useLoaderData` reads). */
|
|
58
|
+
loaderContext: Context<unknown> | null;
|
|
59
|
+
/** The markers' build switch (same module instance the page imports). */
|
|
60
|
+
setSsrBuild: (on: boolean) => void;
|
|
61
|
+
/** The built HTML shell (with hashed script tags) to splice into. */
|
|
62
|
+
shell: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TemplateArtifacts {
|
|
66
|
+
name: string;
|
|
67
|
+
tmpl: Buffer;
|
|
68
|
+
/** Binary `.slots` manifest for the Rust host. */
|
|
69
|
+
slotsBin: Buffer;
|
|
70
|
+
/** Generated AssemblyScript `Slot` enum + `HASH` module source. */
|
|
71
|
+
slotsModule: string;
|
|
72
|
+
hash: Buffer;
|
|
73
|
+
slotCount: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build the route element tree: layouts (outermost first) wrapping the page,
|
|
77
|
+
* under the loader-data provider. The Suspense/RoutePage wrappers the client
|
|
78
|
+
* adds contribute no DOM, so this reproduces the client's markup. */
|
|
79
|
+
export function assembleRouteElement(
|
|
80
|
+
Page: ComponentType,
|
|
81
|
+
layouts: ComponentType<{ children?: ReactNode }>[],
|
|
82
|
+
loaderData: unknown,
|
|
83
|
+
loaderContext: Context<unknown> | null,
|
|
84
|
+
): ReactNode {
|
|
85
|
+
let node: ReactNode = createElement(Page);
|
|
86
|
+
if (loaderContext) {
|
|
87
|
+
node = createElement(loaderContext.Provider, { value: loaderData }, node);
|
|
88
|
+
}
|
|
89
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
90
|
+
node = createElement(layouts[i], null, node);
|
|
91
|
+
}
|
|
92
|
+
return node;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Splice the rendered route HTML into the shell's `#root` and add the SSR
|
|
96
|
+
* marker so the client hydrates rather than client-renders. */
|
|
97
|
+
export function injectIntoShell(shell: string, routeHtml: string): string {
|
|
98
|
+
if (!shell.includes(ROOT_DIV)) {
|
|
99
|
+
throw new Error('toil ssr: built shell has no empty <div id="root"></div> to splice into');
|
|
100
|
+
}
|
|
101
|
+
return shell.replace(ROOT_DIV, `<div id="root">${routeHtml}</div>${SSR_MARKER}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Render one route to its template artifacts (pure given its inputs). */
|
|
105
|
+
export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts {
|
|
106
|
+
const element = assembleRouteElement(
|
|
107
|
+
input.Page,
|
|
108
|
+
input.layouts,
|
|
109
|
+
input.loaderData,
|
|
110
|
+
input.loaderContext,
|
|
111
|
+
);
|
|
112
|
+
input.setSsrBuild(true);
|
|
113
|
+
let routeHtml: string;
|
|
114
|
+
try {
|
|
115
|
+
routeHtml = renderToStaticMarkup(element);
|
|
116
|
+
} finally {
|
|
117
|
+
input.setSsrBuild(false);
|
|
118
|
+
}
|
|
119
|
+
const full = injectIntoShell(input.shell, routeHtml);
|
|
120
|
+
const extracted: Extracted = extractFromHtml(full);
|
|
121
|
+
const ids = assignSlotIds(extracted.slots);
|
|
122
|
+
const hash = coherenceHash(extracted.tmpl, extracted.slots);
|
|
123
|
+
return {
|
|
124
|
+
name: input.name,
|
|
125
|
+
tmpl: extracted.tmpl,
|
|
126
|
+
slotsBin: encodeSlots(extracted.tmpl.length, hash, extracted.slots, ids),
|
|
127
|
+
slotsModule: generateSlotsModule(input.name, extracted.slots, hash),
|
|
128
|
+
hash,
|
|
129
|
+
slotCount: extracted.slots.length,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Write a route's `.tmpl` / `.slots` / `.slots.ts` into `ssrDir`. */
|
|
134
|
+
export function writeTemplateArtifacts(ssrDir: string, art: TemplateArtifacts): void {
|
|
135
|
+
fs.mkdirSync(ssrDir, { recursive: true });
|
|
136
|
+
fs.writeFileSync(path.join(ssrDir, `${art.name}.tmpl`), art.tmpl);
|
|
137
|
+
fs.writeFileSync(path.join(ssrDir, `${art.name}.slots`), art.slotsBin);
|
|
138
|
+
fs.writeFileSync(path.join(ssrDir, `${art.name}.slots.ts`), art.slotsModule);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A file-safe, identifier-ish name for a route pattern (`/u/:name` -> `u_name`). */
|
|
142
|
+
export function routeTemplateName(pattern: string): string {
|
|
143
|
+
const n = pattern.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
144
|
+
return n.length > 0 ? n : 'index';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Synthesize a sample param set for a pattern's dynamic segments. */
|
|
148
|
+
function sampleParams(pattern: string): Record<string, string> {
|
|
149
|
+
const params: Record<string, string> = {};
|
|
150
|
+
for (const m of pattern.matchAll(/[:*]+([A-Za-z0-9_]+)/g)) {
|
|
151
|
+
params[m[1]] = 'sample';
|
|
152
|
+
}
|
|
153
|
+
return params;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface RouteModule {
|
|
157
|
+
default: ComponentType;
|
|
158
|
+
ssr?: boolean;
|
|
159
|
+
loader?: (args: { params: Record<string, string>; searchParams: URLSearchParams }) => unknown;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render every `export const ssr = true` route to `<outDir>/_ssr/<name>.{tmpl,
|
|
164
|
+
* slots,slots.ts}` + a `templates.json` index, and copy the `.tmpl`/`.slots`
|
|
165
|
+
* into the edge host bundle at `hosts/<host>/_tmpl/`. Returns the patterns
|
|
166
|
+
* generated. Skips (with a warning) any route that throws under static markup.
|
|
167
|
+
*/
|
|
168
|
+
export async function extractTemplates(
|
|
169
|
+
cfg: ResolvedToilConfig,
|
|
170
|
+
hostName = 'edge',
|
|
171
|
+
): Promise<string[]> {
|
|
172
|
+
const routes = scanRoutes(cfg.routesAbsDir).filter((r) => r.slot === undefined && !r.intercept);
|
|
173
|
+
if (routes.length === 0) return [];
|
|
174
|
+
|
|
175
|
+
const outDir = path.resolve(cfg.root, cfg.outDir);
|
|
176
|
+
const stashed = path.join(cfg.toilDir, 'shell.html');
|
|
177
|
+
const shellPath = fs.existsSync(stashed) ? stashed : path.join(outDir, 'index.html');
|
|
178
|
+
if (!fs.existsSync(shellPath)) return [];
|
|
179
|
+
const shell = fs.readFileSync(shellPath, 'utf8');
|
|
180
|
+
|
|
181
|
+
const warn = (msg: string): void => {
|
|
182
|
+
process.stderr.write(` toil: SSR ${msg}\n`);
|
|
183
|
+
};
|
|
184
|
+
const server = await createServer({
|
|
185
|
+
...(await createViteConfig(cfg)),
|
|
186
|
+
server: { middlewareMode: true, hmr: false },
|
|
187
|
+
appType: 'custom',
|
|
188
|
+
logLevel: 'silent',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const client = (await server.ssrLoadModule('toiljs/client')) as unknown as {
|
|
192
|
+
__setSsrBuild: (on: boolean) => void;
|
|
193
|
+
LoaderDataContext: Context<unknown>;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const ssrDir = path.join(outDir, '_ssr');
|
|
197
|
+
const hostsTmplDir = path.join(cfg.root, 'hosts', hostName, '_tmpl');
|
|
198
|
+
const generated: string[] = [];
|
|
199
|
+
const index: { route: string; name: string; hash: string }[] = [];
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
for (const r of routes) {
|
|
203
|
+
let mod: RouteModule;
|
|
204
|
+
try {
|
|
205
|
+
mod = (await server.ssrLoadModule(r.file)) as unknown as RouteModule;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
warn(`skipped ${r.pattern} (${err instanceof Error ? err.message : String(err)})`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (mod.ssr !== true) continue;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const params = sampleParams(r.pattern);
|
|
214
|
+
const loaderData =
|
|
215
|
+
typeof mod.loader === 'function'
|
|
216
|
+
? await mod.loader({ params, searchParams: new URLSearchParams() })
|
|
217
|
+
: undefined;
|
|
218
|
+
|
|
219
|
+
const layoutFiles = [
|
|
220
|
+
...(findLayout(cfg) ? [findLayout(cfg)!] : []),
|
|
221
|
+
...findSpecialChain(cfg, r.file, 'layout', false),
|
|
222
|
+
];
|
|
223
|
+
const layouts: ComponentType<{ children?: ReactNode }>[] = [];
|
|
224
|
+
for (const lf of layoutFiles) {
|
|
225
|
+
const lm = (await server.ssrLoadModule(lf)) as unknown as {
|
|
226
|
+
default: ComponentType<{ children?: ReactNode }>;
|
|
227
|
+
};
|
|
228
|
+
layouts.push(lm.default);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const name = routeTemplateName(r.pattern);
|
|
232
|
+
const art = extractRouteTemplate({
|
|
233
|
+
name,
|
|
234
|
+
Page: mod.default,
|
|
235
|
+
layouts,
|
|
236
|
+
loaderData,
|
|
237
|
+
loaderContext: client.LoaderDataContext,
|
|
238
|
+
setSsrBuild: client.__setSsrBuild,
|
|
239
|
+
shell,
|
|
240
|
+
});
|
|
241
|
+
writeTemplateArtifacts(ssrDir, art);
|
|
242
|
+
fs.mkdirSync(hostsTmplDir, { recursive: true });
|
|
243
|
+
fs.copyFileSync(
|
|
244
|
+
path.join(ssrDir, `${name}.tmpl`),
|
|
245
|
+
path.join(hostsTmplDir, `${name}.tmpl`),
|
|
246
|
+
);
|
|
247
|
+
fs.copyFileSync(
|
|
248
|
+
path.join(ssrDir, `${name}.slots`),
|
|
249
|
+
path.join(hostsTmplDir, `${name}.slots`),
|
|
250
|
+
);
|
|
251
|
+
index.push({ route: r.pattern, name, hash: art.hash.toString('hex') });
|
|
252
|
+
generated.push(r.pattern);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
warn(
|
|
255
|
+
`skipped ${r.pattern} (render failed: ${
|
|
256
|
+
err instanceof Error ? err.message : String(err)
|
|
257
|
+
}) — falls back to client rendering`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
await server.close();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (generated.length > 0) {
|
|
266
|
+
fs.mkdirSync(ssrDir, { recursive: true });
|
|
267
|
+
fs.writeFileSync(path.join(ssrDir, 'templates.json'), JSON.stringify(index, null, 2));
|
|
268
|
+
process.stdout.write(
|
|
269
|
+
` ✓ extracted ${String(generated.length)} SSR template${
|
|
270
|
+
generated.length === 1 ? '' : 's'
|
|
271
|
+
}\n`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return generated;
|
|
275
|
+
}
|