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
@@ -14,6 +14,15 @@
14
14
  * `Slot` enum + `HASH`. A route (or layout) that throws under static markup
15
15
  * (e.g. it uses router hooks outside the supported subset) is skipped with a
16
16
  * warning and falls back to pure client rendering.
17
+ *
18
+ * The `Slot` enum + `HASH` is also what the SERVER `render` imports, so it must
19
+ * exist BEFORE the server compiles. The build therefore runs the render in two
20
+ * passes: a slots PRE-PASS (`extractServerSlots`, before the server build) emits
21
+ * the server-importable `<server>/_ssr/<name>.slots.ts` so toilscript can compile
22
+ * the `render`; the FINAL pass (`extractTemplates`, after the Vite client build)
23
+ * rewrites it against the real built shell so the `HASH` is authoritative, and
24
+ * reports whether it rotated so the caller can recompile the server once. This is
25
+ * what makes a clean build need ZERO hand-maintained slots.
17
26
  */
18
27
 
19
28
  import fs from 'node:fs';
@@ -21,7 +30,7 @@ import { createRequire } from 'node:module';
21
30
  import path from 'node:path';
22
31
 
23
32
  import { type ComponentType, type Context, createElement, type ReactNode } from 'react';
24
- import { renderToStaticMarkup } from 'react-dom/server';
33
+ import { renderToString } from 'react-dom/server';
25
34
  import { createServer } from 'vite';
26
35
 
27
36
  import { type ResolvedToilConfig } from './config.js';
@@ -62,8 +71,10 @@ export interface RouteRenderInput {
62
71
  * React copies leaves the hook dispatcher null ("Cannot read properties of
63
72
  * null (reading 'useRef')"). */
64
73
  createElement?: typeof createElement;
65
- /** `renderToStaticMarkup` paired with {@link createElement}'s React. */
66
- renderToStaticMarkup?: typeof renderToStaticMarkup;
74
+ /** `renderToString` paired with {@link createElement}'s React. We use it (NOT
75
+ * `renderToStaticMarkup`) because hydration needs the `<!-- -->` text-node
76
+ * boundary markers it emits, so `hydrateRoot` can align "text + hole" runs. */
77
+ renderToString?: typeof renderToString;
67
78
  }
68
79
 
69
80
  export interface TemplateArtifacts {
@@ -106,10 +117,25 @@ export function injectIntoShell(shell: string, routeHtml: string): string {
106
117
  return shell.replace(ROOT_DIV, `<div id="root">${routeHtml}</div>${SSR_MARKER}`);
107
118
  }
108
119
 
120
+ /**
121
+ * React 19 auto-emits hoistable resource tags into `<head>` on the client (it
122
+ * adds a `<link rel="preload">` for an `<img>`, and hoists `<title>` / `<meta>`),
123
+ * but the string renderer emits them INLINE in the route fragment. Left in the
124
+ * spliced `#root` template they would not appear in the client's hydrated `#root`
125
+ * (the client puts them in `<head>`), so `hydrateRoot` reports a mismatch. Strip
126
+ * them from the route fragment; the shell already carries the document head, and
127
+ * the client re-adds its own resource hints. Only the fragment is stripped. */
128
+ function stripHoistedResourceTags(html: string): string {
129
+ return html
130
+ .replace(/<link\b[^>]*>/gi, '')
131
+ .replace(/<meta\b[^>]*>/gi, '')
132
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '');
133
+ }
134
+
109
135
  /** Render one route to its template artifacts (pure given its inputs). */
110
136
  export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts {
111
137
  const h = input.createElement ?? createElement;
112
- const render = input.renderToStaticMarkup ?? renderToStaticMarkup;
138
+ const render = input.renderToString ?? renderToString;
113
139
  const element = assembleRouteElement(
114
140
  input.Page,
115
141
  input.layouts,
@@ -124,7 +150,7 @@ export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts
124
150
  } finally {
125
151
  input.setSsrBuild(false);
126
152
  }
127
- const full = injectIntoShell(input.shell, routeHtml);
153
+ const full = injectIntoShell(input.shell, stripHoistedResourceTags(routeHtml));
128
154
  const extracted: Extracted = extractFromHtml(full);
129
155
  const ids = assignSlotIds(extracted.slots);
130
156
  const hash = coherenceHash(extracted.tmpl, extracted.slots);
@@ -146,46 +172,81 @@ export function writeTemplateArtifacts(ssrDir: string, art: TemplateArtifacts):
146
172
  fs.writeFileSync(path.join(ssrDir, `${art.name}.slots.ts`), art.slotsModule);
147
173
  }
148
174
 
149
- /** A file-safe, identifier-ish name for a route pattern (`/u/:name` -> `u_name`). */
150
- export function routeTemplateName(pattern: string): string {
151
- const n = pattern.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
152
- return n.length > 0 ? n : 'index';
175
+ /**
176
+ * The server source dir (where toilscript-compiled modules live): the dir of the first toilconfig
177
+ * entry, else `<root>/server`. Mirrors the same resolution in `emails.ts` (`_emails.ts` lives here).
178
+ */
179
+ function serverDir(root: string): string {
180
+ try {
181
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
182
+ entries?: unknown;
183
+ };
184
+ const first = Array.isArray(cfg.entries)
185
+ ? cfg.entries.find((e): e is string => typeof e === 'string')
186
+ : undefined;
187
+ if (first) return path.dirname(path.resolve(root, first));
188
+ } catch {
189
+ // fall through to the default
190
+ }
191
+ return path.join(root, 'server');
153
192
  }
154
193
 
155
- /** Synthesize a sample param set for a pattern's dynamic segments. */
156
- function sampleParams(pattern: string): Record<string, string> {
157
- const params: Record<string, string> = {};
158
- for (const m of pattern.matchAll(/[:*]+([A-Za-z0-9_]+)/g)) {
159
- params[m[1]] = 'sample';
194
+ /**
195
+ * The build-owned dir that holds the GENERATED, server-importable `<name>.slots.ts` modules
196
+ * (the `Slot` enum + `HASH` the guest `render` imports). It sits inside the server source tree so
197
+ * toilscript compiles it, named `_ssr` to match the generated `_emails.ts` convention, and is
198
+ * gitignored + regenerated every build — never hand-edited. The server `render` imports
199
+ * `./_ssr/<name>.slots`.
200
+ */
201
+ export function serverSlotsDir(root: string): string {
202
+ return path.join(serverDir(root), '_ssr');
203
+ }
204
+
205
+ /**
206
+ * (Re)write the generated `<server>/_ssr/<name>.slots.ts` module(s) the server `render` imports.
207
+ * Returns the map of `name -> module source` actually on disk afterwards, so the caller can detect
208
+ * whether a later authoritative extraction changed the `HASH` and a server recompile is needed.
209
+ * Only rewrites a file whose content actually changed (keeps mtimes stable for the dev watcher).
210
+ */
211
+ export function writeServerSlotsModules(
212
+ root: string,
213
+ modules: { name: string; slotsModule: string }[],
214
+ ): Map<string, string> {
215
+ const dir = serverSlotsDir(root);
216
+ const written = new Map<string, string>();
217
+ if (modules.length === 0) return written;
218
+ fs.mkdirSync(dir, { recursive: true });
219
+ for (const m of modules) {
220
+ const file = path.join(dir, `${m.name}.slots.ts`);
221
+ let prev: string | null = null;
222
+ try {
223
+ prev = fs.readFileSync(file, 'utf8');
224
+ } catch {
225
+ // first build for this route: no prior module
226
+ }
227
+ if (prev !== m.slotsModule) fs.writeFileSync(file, m.slotsModule);
228
+ written.set(m.name, m.slotsModule);
160
229
  }
161
- return params;
230
+ return written;
162
231
  }
163
232
 
164
- interface RouteModule {
165
- default: ComponentType;
166
- ssr?: boolean;
167
- loader?: (args: { params: Record<string, string>; searchParams: URLSearchParams }) => unknown;
233
+ /** A rendered SSR route plus the artifacts the build emits for it. */
234
+ interface RenderedRoute {
235
+ pattern: string;
236
+ art: TemplateArtifacts;
168
237
  }
169
238
 
170
239
  /**
171
- * Render every `export const ssr = true` route to `<outDir>/_ssr/<name>.{tmpl,
172
- * slots,slots.ts}` + a `templates.json` index, and copy the `.tmpl`/`.slots`
173
- * into the edge host bundle at `hosts/<host>/_tmpl/`. Returns the patterns
174
- * generated. Skips (with a warning) any route that throws under static markup.
240
+ * Spin up a short-lived Vite SSR server, render every `export const ssr = true` route under its
241
+ * layout chain (with sample loader data, in sentinel mode) against `shell`, and return the
242
+ * per-route artifacts. Shared by the pre-pass (`extractServerSlots`, slots only) and the final
243
+ * `extractTemplates` (full artifacts). A route (or layout) that throws under static markup is
244
+ * skipped with a warning and omitted from the result, so it falls back to client rendering.
175
245
  */
176
- export async function extractTemplates(
177
- cfg: ResolvedToilConfig,
178
- hostName = 'edge',
179
- ): Promise<string[]> {
246
+ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<RenderedRoute[]> {
180
247
  const routes = scanRoutes(cfg.routesAbsDir).filter((r) => r.slot === undefined && !r.intercept);
181
248
  if (routes.length === 0) return [];
182
249
 
183
- const outDir = path.resolve(cfg.root, cfg.outDir);
184
- const stashed = path.join(cfg.toilDir, 'shell.html');
185
- const shellPath = fs.existsSync(stashed) ? stashed : path.join(outDir, 'index.html');
186
- if (!fs.existsSync(shellPath)) return [];
187
- const shell = fs.readFileSync(shellPath, 'utf8');
188
-
189
250
  const warn = (msg: string): void => {
190
251
  process.stderr.write(` toil: SSR ${msg}\n`);
191
252
  };
@@ -220,14 +281,10 @@ export async function extractTemplates(
220
281
  const appRequire = createRequire(path.join(cfg.root, 'package.json'));
221
282
  const react = appRequire('react') as { createElement: typeof createElement };
222
283
  const reactDomServer = appRequire('react-dom/server') as {
223
- renderToStaticMarkup: typeof renderToStaticMarkup;
284
+ renderToString: typeof renderToString;
224
285
  };
225
286
 
226
- const ssrDir = path.join(outDir, '_ssr');
227
- const hostsTmplDir = path.join(cfg.root, 'hosts', hostName, '_tmpl');
228
- const generated: string[] = [];
229
- const index: { route: string; name: string; hash: string }[] = [];
230
-
287
+ const rendered: RenderedRoute[] = [];
231
288
  try {
232
289
  for (const r of routes) {
233
290
  let mod: RouteModule;
@@ -268,20 +325,9 @@ export async function extractTemplates(
268
325
  setSsrBuild: client.__setSsrBuild,
269
326
  shell,
270
327
  createElement: react.createElement,
271
- renderToStaticMarkup: reactDomServer.renderToStaticMarkup,
328
+ renderToString: reactDomServer.renderToString,
272
329
  });
273
- writeTemplateArtifacts(ssrDir, art);
274
- fs.mkdirSync(hostsTmplDir, { recursive: true });
275
- fs.copyFileSync(
276
- path.join(ssrDir, `${name}.tmpl`),
277
- path.join(hostsTmplDir, `${name}.tmpl`),
278
- );
279
- fs.copyFileSync(
280
- path.join(ssrDir, `${name}.slots`),
281
- path.join(hostsTmplDir, `${name}.slots`),
282
- );
283
- index.push({ route: r.pattern, name, hash: art.hash.toString('hex') });
284
- generated.push(r.pattern);
330
+ rendered.push({ pattern: r.pattern, art });
285
331
  } catch (err) {
286
332
  warn(
287
333
  `skipped ${r.pattern} (render failed: ${
@@ -293,6 +339,178 @@ export async function extractTemplates(
293
339
  } finally {
294
340
  await server.close();
295
341
  }
342
+ return rendered;
343
+ }
344
+
345
+ /**
346
+ * Resolve the HTML shell to splice routes into. The authoritative shell is the BUILT (post-Vite)
347
+ * `index.html`, whose hashed `<script>`/`<link>` tags are part of the template and therefore the
348
+ * coherence `HASH`. `preferBuilt` (the final extraction) demands it; the slots PRE-PASS (which runs
349
+ * before Vite) falls back to the previous build's shell when present (so an unchanged rebuild's
350
+ * pre-pass `HASH` already matches the final one and no server recompile is needed), and finally to
351
+ * the un-built `.toil/index.html` template on a first clean build. Returns `null` when no shell
352
+ * exists at all (no client build yet and no template), so the caller no-ops.
353
+ */
354
+ function resolveShell(cfg: ResolvedToilConfig, preferBuilt: boolean): string | null {
355
+ const outDir = path.resolve(cfg.root, cfg.outDir);
356
+ const builtIndex = path.join(outDir, 'index.html');
357
+ const stashed = path.join(cfg.toilDir, 'shell.html');
358
+ const templateIndex = path.join(cfg.toilDir, 'index.html');
359
+ const order = preferBuilt
360
+ ? [stashed, builtIndex]
361
+ : // Pre-pass (before Vite): use a prior build's shell if it exists so the HASH is stable
362
+ // across rebuilds; otherwise the generated (un-built) template, which still yields the
363
+ // correct Slot ids (the final pass reconciles the HASH).
364
+ [builtIndex, stashed, templateIndex];
365
+ for (const p of order) {
366
+ if (fs.existsSync(p)) return fs.readFileSync(p, 'utf8');
367
+ }
368
+ return null;
369
+ }
370
+
371
+ /**
372
+ * SLOTS PRE-PASS (runs BEFORE the server build): render every `ssr = true` route to its `Slot`
373
+ * enum + `HASH` and write the server-importable `<server>/_ssr/<name>.slots.ts` module(s), so the
374
+ * server `render` can import them when toilscript compiles it. On a clean build no `.slots.ts`
375
+ * exists yet, so this is what bootstraps it; on a rebuild it refreshes them. The `Slot` ids are
376
+ * always correct (they depend only on the route's hole structure); the `HASH` is final only when a
377
+ * prior build's shell is reused, so the final `extractTemplates` reconciles it (and the caller
378
+ * recompiles the server if it rotated). Returns the modules written keyed by route name.
379
+ */
380
+ export async function extractServerSlots(cfg: ResolvedToilConfig): Promise<Map<string, string>> {
381
+ const shell = resolveShell(cfg, false);
382
+ if (shell === null) return new Map();
383
+ const rendered = await renderSsrRoutes(cfg, shell);
384
+ return writeServerSlotsModules(
385
+ cfg.root,
386
+ rendered.map((r) => ({ name: r.art.name, slotsModule: r.art.slotsModule })),
387
+ );
388
+ }
389
+
390
+ /** One SSR route's in-memory dev template: the spliceable `.tmpl` plus its
391
+ * top-level slot insertion points (numeric id -> byte offset), parsed from the
392
+ * `.slots` manifest. Used by the dev server to serve SSR without a prod build. */
393
+ export interface DevSsrTemplate {
394
+ pattern: string;
395
+ name: string;
396
+ tmpl: Buffer;
397
+ entries: { id: number; offset: number }[];
398
+ }
399
+
400
+ /**
401
+ * Render every `ssr = true` route against the given (live, Vite-transformed) dev
402
+ * `shell` into an in-memory template + slot offsets, for the dev server to splice
403
+ * the guest `render` values into. Unlike {@link extractTemplates} this has NO
404
+ * side effects (it writes nothing and does not touch `server/_ssr` or the host
405
+ * bundle); the dev server builds together with the guest, so there is no hash to
406
+ * reconcile. Returns `[]` when no route opts in.
407
+ */
408
+ export async function extractDevSsrTemplates(
409
+ cfg: ResolvedToilConfig,
410
+ shell: string,
411
+ ): Promise<DevSsrTemplate[]> {
412
+ const rendered = await renderSsrRoutes(cfg, shell);
413
+ return rendered.map(({ pattern, art }) => {
414
+ // Parse the `.slots` manifest: 46-byte header (n_slots at offset 44),
415
+ // then 8-byte entries (u32 offset, u16 id, u8 kind, u8 reserved).
416
+ const bin = art.slotsBin;
417
+ const n = bin.readUInt16LE(44);
418
+ const entries: { id: number; offset: number }[] = [];
419
+ let o = 46;
420
+ for (let i = 0; i < n; i++) {
421
+ entries.push({ offset: bin.readUInt32LE(o), id: bin.readUInt16LE(o + 4) });
422
+ o += 8;
423
+ }
424
+ return { pattern, name: art.name, tmpl: art.tmpl, entries };
425
+ });
426
+ }
427
+
428
+ /** A file-safe, identifier-ish name for a route pattern (`/u/:name` -> `u_name`). */
429
+ export function routeTemplateName(pattern: string): string {
430
+ const n = pattern.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
431
+ return n.length > 0 ? n : 'index';
432
+ }
433
+
434
+ /** Synthesize a sample param set for a pattern's dynamic segments. */
435
+ function sampleParams(pattern: string): Record<string, string> {
436
+ const params: Record<string, string> = {};
437
+ for (const m of pattern.matchAll(/[:*]+([A-Za-z0-9_]+)/g)) {
438
+ params[m[1]] = 'sample';
439
+ }
440
+ return params;
441
+ }
442
+
443
+ interface RouteModule {
444
+ default: ComponentType;
445
+ ssr?: boolean;
446
+ loader?: (args: { params: Record<string, string>; searchParams: URLSearchParams }) => unknown;
447
+ }
448
+
449
+ /** The outcome of the final {@link extractTemplates} pass. */
450
+ export interface ExtractResult {
451
+ /** The route patterns that produced a template. */
452
+ readonly generated: string[];
453
+ /**
454
+ * Whether the AUTHORITATIVE `<server>/_ssr/<name>.slots.ts` module(s) just written differ from
455
+ * the ones the server was already compiled against (`priorServerSlots`). True means the `HASH`
456
+ * (or `Slot` ids) rotated after the server build, so the server must be recompiled to bake the
457
+ * correct `HASH` (otherwise the host rejects the guest's stale hash as a deploy skew).
458
+ */
459
+ readonly serverSlotsChanged: boolean;
460
+ }
461
+
462
+ /**
463
+ * FINAL pass (runs AFTER the client/Vite build): render every `export const ssr = true` route to
464
+ * `<outDir>/_ssr/<name>.{tmpl,slots,slots.ts}` + a `templates.json` index, copy the `.tmpl`/`.slots`
465
+ * into the edge host bundle at `hosts/<host>/_tmpl/`, AND (re)write the server-importable
466
+ * `<server>/_ssr/<name>.slots.ts` module(s) — now against the real built shell, so the `HASH` is
467
+ * authoritative. Skips (with a warning) any route that throws under static markup.
468
+ *
469
+ * `priorServerSlots` is the module map the slots PRE-PASS wrote (what the server was compiled
470
+ * against); compared against the authoritative modules here to report whether a server recompile is
471
+ * needed (see {@link ExtractResult.serverSlotsChanged}).
472
+ */
473
+ export async function extractTemplates(
474
+ cfg: ResolvedToilConfig,
475
+ hostName = 'edge',
476
+ priorServerSlots: Map<string, string> = new Map(),
477
+ ): Promise<ExtractResult> {
478
+ const shell = resolveShell(cfg, true);
479
+ if (shell === null) return { generated: [], serverSlotsChanged: false };
480
+
481
+ const rendered = await renderSsrRoutes(cfg, shell);
482
+
483
+ const outDir = path.resolve(cfg.root, cfg.outDir);
484
+ const ssrDir = path.join(outDir, '_ssr');
485
+ const hostsTmplDir = path.join(cfg.root, 'hosts', hostName, '_tmpl');
486
+ const generated: string[] = [];
487
+ const index: { route: string; name: string; hash: string }[] = [];
488
+
489
+ for (const { pattern, art } of rendered) {
490
+ writeTemplateArtifacts(ssrDir, art);
491
+ fs.mkdirSync(hostsTmplDir, { recursive: true });
492
+ fs.copyFileSync(
493
+ path.join(ssrDir, `${art.name}.tmpl`),
494
+ path.join(hostsTmplDir, `${art.name}.tmpl`),
495
+ );
496
+ fs.copyFileSync(
497
+ path.join(ssrDir, `${art.name}.slots`),
498
+ path.join(hostsTmplDir, `${art.name}.slots`),
499
+ );
500
+ index.push({ route: pattern, name: art.name, hash: art.hash.toString('hex') });
501
+ generated.push(pattern);
502
+ }
503
+
504
+ // Write the AUTHORITATIVE server-importable slots module(s) (the real built-shell HASH) and
505
+ // detect whether they changed since the pre-pass the server was compiled against.
506
+ const authoritative = writeServerSlotsModules(
507
+ cfg.root,
508
+ rendered.map((r) => ({ name: r.art.name, slotsModule: r.art.slotsModule })),
509
+ );
510
+ let serverSlotsChanged = false;
511
+ for (const [name, mod] of authoritative) {
512
+ if (priorServerSlots.get(name) !== mod) serverSlotsChanged = true;
513
+ }
296
514
 
297
515
  if (generated.length > 0) {
298
516
  fs.mkdirSync(ssrDir, { recursive: true });
@@ -303,5 +521,5 @@ export async function extractTemplates(
303
521
  }\n`,
304
522
  );
305
523
  }
306
- return generated;
524
+ return { generated, serverSlotsChanged };
307
525
  }