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
@@ -15,15 +15,23 @@ import { loadConfig, type ResolvedToilConfig } from './config.js';
15
15
  import { renderEmails } from './emails.js';
16
16
  import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
17
17
  import { prerenderStaticParams } from './ssg.js';
18
- import { extractTemplates } from './template-build.js';
18
+ import {
19
+ type DevSsrTemplate,
20
+ extractDevSsrTemplates,
21
+ extractServerSlots,
22
+ extractTemplates,
23
+ } from './template-build.js';
19
24
  import { createViteConfig } from './vite.js';
20
25
 
21
26
  /**
22
- * A `@data`/`@rest`/`@service`/`@remote` declaration - a file with one defines client surface.
23
- * Anchored to line-start (after indentation) so a mention in a comment (e.g. `// the @rest ...`)
24
- * does not count.
27
+ * A surface declaration - a file with one defines client and/or server surface, so it must be
28
+ * handed to toilscript even when it is not a `toilconfig.json` entry. Matches the request/RPC
29
+ * surface (`@data`/`@rest`/`@service`/`@remote`) and the streams/daemon surface
30
+ * (`@stream`/`@daemon`/`@scheduled`); without the latter, a file whose ONLY decorator is `@daemon`
31
+ * or `@scheduled` would silently vanish from the cold artifact. Anchored to line-start (after
32
+ * indentation) so a mention in a comment (e.g. `// the @rest ...`) does not count.
25
33
  */
26
- const SURFACE_DECORATOR = /^[ \t]*@(data|rest|service|remote)\b/m;
34
+ export const SURFACE_DECORATOR = /^[ \t]*@(data|rest|service|remote|stream|daemon|scheduled)\b/m;
27
35
 
28
36
  /** The toilconfig `entries` (relative paths), or `null` when there is no readable toilconfig. */
29
37
  function toilconfigEntries(root: string): string[] | null {
@@ -107,7 +115,7 @@ function serverEntryFiles(root: string): string[] {
107
115
  * toilconfig entries) so dropped-in `@data`/`@rest` files are picked up. Runs the locally
108
116
  * installed `toilscript`, resolved + invoked via Node (no `.bin` shim / PATH assumptions).
109
117
  */
110
- async function buildServer(root: string): Promise<void> {
118
+ export async function buildServer(root: string): Promise<void> {
111
119
  if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return;
112
120
 
113
121
  // Regenerate the editor-only server-globals d.ts each build (the same way
@@ -123,8 +131,50 @@ async function buildServer(root: string): Promise<void> {
123
131
  }
124
132
  }
125
133
 
134
+ const binJs = resolveToilscriptBin(root);
135
+
136
+ // Explicit entries (every server file) override the toilconfig entries; the target options
137
+ // (optimization, features, runtime) still come from the toilconfig's `release` target.
138
+ const files = serverEntryFiles(root);
139
+
140
+ // A project that declares a `@daemon` (cold surface) compiles the ONE source tree into TWO
141
+ // artifacts via two toilscript passes (one per --targetMode); a project with only the legacy
142
+ // request surface keeps the single-artifact path (byte-identical to before). The cold pass
143
+ // runs FIRST (cheap, no client surface); the hot pass runs LAST because it (re)writes
144
+ // shared/server.ts via --rpcModule, which the downstream client build imports.
145
+ const split = splitSurfaceFiles(root, files);
146
+ if (split.hasDaemon) {
147
+ const artifacts = serverArtifacts(root);
148
+ // toilscript's gating matrix HARD-ERRORS a `@daemon`/`@scheduled` class compiled under
149
+ // `--targetMode hot` (and a `@rest`/`@stream`/`@service`/`@remote` class under cold). So
150
+ // each pass is handed only the files eligible for that mode: the cold pass drops hot-only
151
+ // files, the hot pass drops daemon-only files. `@data`/`@database`/plain files are shared.
152
+ await runToilscriptPass(root, binJs, split.cold, {
153
+ mode: 'cold',
154
+ outFile: artifacts.cold,
155
+ withRpc: false,
156
+ });
157
+ // The hot pass writes the legacy `outFile` (= hotFile alias, AN-1) so the request path
158
+ // and the dev server's `serverWasmFile` are unchanged; the request box loads it as today.
159
+ // A daemon-only project (no request/stream surface) has no hot files; skip the hot pass so
160
+ // toilscript is not handed an empty entry set. The request path then stays idle (no
161
+ // `handle` export), which is correct for a pure background worker.
162
+ if (split.hot.length > 0)
163
+ await runToilscriptPass(root, binJs, split.hot, {
164
+ mode: 'hot',
165
+ outFile: serverWasmFile(root),
166
+ withRpc: true,
167
+ });
168
+ return;
169
+ }
170
+
171
+ // Legacy single-artifact path (no daemon surface): exactly today's invocation.
172
+ await runToilscriptPass(root, binJs, files, { mode: null, outFile: null, withRpc: true });
173
+ }
174
+
175
+ /** Resolve the locally installed `toilscript` bin via Node (no `.bin` shim / PATH assumptions). */
176
+ function resolveToilscriptBin(root: string): string {
126
177
  const require = createRequire(path.join(root, 'package.json'));
127
- let binJs: string;
128
178
  try {
129
179
  const pkgPath = require.resolve('toilscript/package.json');
130
180
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as {
@@ -132,38 +182,101 @@ async function buildServer(root: string): Promise<void> {
132
182
  };
133
183
  const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.toilscript;
134
184
  if (!binRel) throw new Error('toilscript declares no bin');
135
- binJs = path.join(path.dirname(pkgPath), binRel);
185
+ return path.join(path.dirname(pkgPath), binRel);
136
186
  } catch {
137
187
  throw new Error(
138
188
  "toiljs: this project has a server target (toilconfig.json) but 'toilscript' is not " +
139
189
  'installed. Run `npm i -D toilscript`, or remove toilconfig.json for a client-only build.',
140
190
  );
141
191
  }
192
+ }
142
193
 
143
- // Explicit entries (every server file) override the toilconfig entries; the target options
144
- // (optimization, features, runtime) still come from the toilconfig's `release` target.
145
- const files = serverEntryFiles(root);
146
- // Suppress AS235 ("only variables/functions/enums become wasm exports"): a
147
- // `@data`/`@rest` class is intentionally `export class` (so other server
148
- // files import it), but never a wasm export — the warning is pure noise here.
149
- const args = [
150
- binJs,
151
- ...files,
152
- '--target',
153
- 'release',
154
- '--rpcModule',
155
- 'shared/server.ts',
156
- '--disableWarning',
157
- '235',
158
- ];
159
-
160
- await new Promise<void>((resolve, reject) => {
194
+ /** Files classified per target mode for the two-pass build. */
195
+ interface SurfaceSplit {
196
+ /** Whether any file declares a `@daemon` (so a cold pass is needed at all). */
197
+ readonly hasDaemon: boolean;
198
+ /** Files eligible for the COLD pass (everything except hot-only request files). */
199
+ readonly cold: string[];
200
+ /** Files eligible for the HOT pass (everything except daemon-only cold files). */
201
+ readonly hot: string[];
202
+ }
203
+
204
+ /** A `@daemon`/`@scheduled` decorator at line start (a cold-only surface). */
205
+ const COLD_DECORATOR = /^[ \t]*@(daemon|scheduled)\b/m;
206
+ /** A request/stream-surface decorator at line start (a hot-only surface). */
207
+ const HOT_DECORATOR = /^[ \t]*@(rest|route|stream|service|remote)\b/m;
208
+
209
+ /**
210
+ * Classify each server source file by the surface decorators it declares, so each toilscript pass
211
+ * is handed only the files valid for its `--targetMode` (toilscript HARD-ERRORS a cold class in
212
+ * the hot artifact and vice versa). A file with a cold-only surface (`@daemon`/`@scheduled` and no
213
+ * hot decorator) is dropped from the hot pass; a file with a hot-only surface is dropped from the
214
+ * cold pass. Shared files (`@data`/`@database`/plain helpers, or a file mixing both surfaces) stay
215
+ * in both passes, matching toilscript's class-level gating which admits `@data`/`@database`
216
+ * everywhere.
217
+ */
218
+ export function splitSurfaceFiles(root: string, files: string[]): SurfaceSplit {
219
+ let hasDaemon = false;
220
+ const cold: string[] = [];
221
+ const hot: string[] = [];
222
+ for (const rel of files) {
223
+ let src = '';
224
+ try {
225
+ src = fs.readFileSync(path.join(root, rel), 'utf8');
226
+ } catch {
227
+ // unreadable: keep it in both passes (let toilscript surface the error).
228
+ cold.push(rel);
229
+ hot.push(rel);
230
+ continue;
231
+ }
232
+ const isCold = COLD_DECORATOR.test(src);
233
+ const isHot = HOT_DECORATOR.test(src);
234
+ if (isCold) hasDaemon ||= /^[ \t]*@daemon\b/m.test(src);
235
+ // Drop a file from the hot pass only when it is cold-only (cold surface, no hot surface);
236
+ // a mixed file stays in both (toilscript gates per class, not per file).
237
+ if (!(isCold && !isHot)) hot.push(rel);
238
+ // Drop a file from the cold pass only when it is hot-only.
239
+ if (!(isHot && !isCold)) cold.push(rel);
240
+ }
241
+ return { hasDaemon, cold, hot };
242
+ }
243
+
244
+ interface PassOptions {
245
+ /** `--targetMode` value; `null` keeps the legacy single-artifact invocation (no flag). */
246
+ readonly mode: 'hot' | 'cold' | null;
247
+ /** Explicit `--outFile` for a two-pass build; `null` uses the toilconfig default. */
248
+ readonly outFile: string | null;
249
+ /** Only the hot/legacy pass carries `--rpcModule` (the cold artifact has no client surface). */
250
+ readonly withRpc: boolean;
251
+ }
252
+
253
+ /** Run one toilscript pass. The toilscript CLI flag is `--targetMode` (camelCase). */
254
+ function runToilscriptPass(
255
+ root: string,
256
+ binJs: string,
257
+ files: string[],
258
+ opts: PassOptions,
259
+ ): Promise<void> {
260
+ // Suppress AS235 ("only variables/functions/enums become wasm exports"): a `@data`/`@rest`
261
+ // class is intentionally `export class` (so other server files import it), but never a wasm
262
+ // export — the warning is pure noise here.
263
+ const args = [binJs, ...files, '--target', 'release'];
264
+ if (opts.mode !== null) args.push('--targetMode', opts.mode);
265
+ if (opts.outFile !== null) args.push('--outFile', opts.outFile);
266
+ if (opts.withRpc) args.push('--rpcModule', 'shared/server.ts');
267
+ args.push('--disableWarning', '235');
268
+
269
+ return new Promise<void>((resolve, reject) => {
161
270
  const child = spawn(process.execPath, args, { cwd: root, stdio: 'inherit' });
162
271
  child.on('error', reject);
163
272
  child.on('close', (code) =>
164
273
  code === 0
165
274
  ? resolve()
166
- : reject(new Error(`toilscript server build failed (exit ${String(code)})`)),
275
+ : reject(
276
+ new Error(
277
+ `toilscript ${opts.mode ?? 'release'} build failed (exit ${String(code)})`,
278
+ ),
279
+ ),
167
280
  );
168
281
  });
169
282
  }
@@ -285,7 +398,8 @@ function installDevShutdown(close: () => Promise<void> | void): void {
285
398
  for (const sig of ['SIGINT', 'SIGTERM'] as const) process.once(sig, shutdown);
286
399
  }
287
400
 
288
- /** The server wasm artifact path from the toilconfig `release` target (toilscript's output). */
401
+ /** The server wasm artifact path from the toilconfig `release` target (toilscript's output).
402
+ * This is the LEGACY single-artifact path (= the hot artifact under the two-pass build). */
289
403
  function serverWasmFile(root: string): string {
290
404
  let outFile = 'build/server/release.wasm';
291
405
  try {
@@ -299,6 +413,39 @@ function serverWasmFile(root: string): string {
299
413
  return path.resolve(root, outFile);
300
414
  }
301
415
 
416
+ /** The hot + cold artifact paths for the two-pass build. `hotFile`/`coldFile` are honored when
417
+ * present in the toilconfig `release` target; otherwise derived from `outFile` by inserting the
418
+ * mode before the extension (`release.wasm` -> `release-hot.wasm` / `release-cold.wasm`). */
419
+ export interface ServerArtifacts {
420
+ /** Absolute path to the hot (request/stream) artifact. */
421
+ readonly hot: string;
422
+ /** Absolute path to the cold (daemon) artifact. */
423
+ readonly cold: string;
424
+ }
425
+ export function serverArtifacts(root: string): ServerArtifacts {
426
+ let out = 'build/server/release.wasm';
427
+ let hot: string | undefined;
428
+ let cold: string | undefined;
429
+ try {
430
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
431
+ targets?: Record<string, { outFile?: string; hotFile?: string; coldFile?: string }>;
432
+ };
433
+ out = cfg.targets?.release?.outFile ?? out;
434
+ hot = cfg.targets?.release?.hotFile;
435
+ cold = cfg.targets?.release?.coldFile;
436
+ } catch {
437
+ // No readable toilconfig: caller already gated on its existence; keep defaults.
438
+ }
439
+ const ins = (mode: 'hot' | 'cold'): string => {
440
+ const ext = path.extname(out);
441
+ return out.slice(0, ext ? -ext.length : undefined) + '-' + mode + (ext || '.wasm');
442
+ };
443
+ return {
444
+ hot: path.resolve(root, hot ?? ins('hot')),
445
+ cold: path.resolve(root, cold ?? ins('cold')),
446
+ };
447
+ }
448
+
302
449
  /** An OS-assigned free loopback port (for the internal Vite server behind the dev front). */
303
450
  async function freeLoopbackPort(): Promise<number> {
304
451
  return new Promise((resolve, reject) => {
@@ -359,9 +506,15 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
359
506
  if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
360
507
  // Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
361
508
  await renderEmails(cfg);
509
+ // Generate the client codegen first so the SSR slots pre-pass can load the route graph, then
510
+ // emit the server-importable `<server>/_ssr/<name>.slots.ts` BEFORE the server build so its
511
+ // `render` can import them. Dev reuses the prior build's shell (or the template) for the HASH;
512
+ // `dispatchRender` checks coherence against the same `.slots`, so a hash drift surfaces as the
513
+ // documented fail-safe 500 until the next full `build`. A no-op without an `ssr = true` route.
514
+ generate(cfg);
515
+ if (hasServer) await extractServerSlots(cfg);
362
516
  await buildServer(cfg.root);
363
517
  if (hasServer) process.stdout.write(pc.green(' ✓ ') + pc.dim('server built') + '\n');
364
- generate(cfg);
365
518
 
366
519
  if (!hasServer) {
367
520
  const server = await createServer(await createViteConfig(cfg));
@@ -380,13 +533,44 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
380
533
  const server = await createServer(viteConfig);
381
534
  await server.listen();
382
535
 
536
+ // Edge SSR in dev: render each `ssr = true` route against the LIVE (Vite-
537
+ // transformed) dev shell into a template-with-holes, so the dev server can
538
+ // splice the guest `render` values into it and serve real server-rendered
539
+ // HTML (the prod-edge path), which then hydrates in place. Extracted once at
540
+ // startup; a route's MARKUP change needs a dev restart to re-extract, but its
541
+ // per-request VALUES are always live via `render`. Best-effort: on failure the
542
+ // routes simply client-render as before.
543
+ let ssrTemplates: DevSsrTemplate[] = [];
544
+ try {
545
+ const rawIndex = fs.readFileSync(path.join(cfg.toilDir, 'index.html'), 'utf8');
546
+ const devShell = await server.transformIndexHtml('/', rawIndex);
547
+ ssrTemplates = await extractDevSsrTemplates(cfg, devShell);
548
+ if (ssrTemplates.length > 0) {
549
+ process.stdout.write(
550
+ pc.green(' ✓ ') +
551
+ pc.dim(`edge SSR: ${String(ssrTemplates.length)} route(s) server-rendered`) +
552
+ '\n',
553
+ );
554
+ }
555
+ } catch (e) {
556
+ process.stdout.write(
557
+ pc.yellow(' ! ') + pc.dim(`SSR dev extraction skipped: ${String(e)}`) + '\n',
558
+ );
559
+ }
560
+
383
561
  const { startDevServer } = await import('toiljs/devserver');
384
562
  const front = await startDevServer({
385
563
  root: cfg.root,
386
564
  port: cfg.port,
387
565
  wasmFile: serverWasmFile(cfg.root),
566
+ // The daemon (cold) emulator drives `release-cold.wasm` per `nodeMode`; absent for a
567
+ // project with no `@daemon` (the cold artifact never gets built, so the host stays idle).
568
+ coldWasmFile: serverArtifacts(cfg.root).cold,
569
+ nodeMode: cfg.nodeMode,
570
+ daemon: cfg.daemon,
388
571
  vite: { host: '127.0.0.1', port: vitePort },
389
572
  email: cfg.email ?? undefined,
573
+ ssrTemplates,
390
574
  });
391
575
  server.httpServer?.once('close', () => {
392
576
  void front.close();
@@ -425,20 +609,40 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
425
609
  process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
426
610
  // Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
427
611
  await renderEmails(cfg);
612
+ // Generate the client codegen (`.toil/globals.ts`, `.toil/index.html`, …) NOW — before the
613
+ // server build — so the SSR slots pre-pass below can load the route/layout module graph and
614
+ // render the opted-in routes.
615
+ generate(cfg);
616
+ // SSR slots PRE-PASS: emit the server-importable `<server>/_ssr/<name>.slots.ts` (the `Slot`
617
+ // enum + `HASH`) the guest `render` imports, so toilscript can compile it. This is what makes a
618
+ // CLEAN build work with zero hand-maintained slots: the modules are generated here, before the
619
+ // server compiles. (The `HASH` is finalized by the post-Vite `extractTemplates` below, which
620
+ // recompiles the server only if it rotated.) A no-op for a project with no `ssr = true` route.
621
+ const priorServerSlots = hasServer ? await extractServerSlots(cfg) : new Map<string, string>();
428
622
  await buildServer(cfg.root);
429
623
  if (opts.serverOnly) return;
430
624
  if (hasServer)
431
625
  process.stdout.write(
432
626
  pc.green(' ✓ ') + pc.dim('server built; building the client (vite)…') + '\n',
433
627
  );
434
- generate(cfg);
435
628
  await viteBuild(await createViteConfig(cfg));
436
629
  // SSG: bake per-URL HTML + sitemap for dynamic routes that opt in via `generateStaticParams`.
437
630
  await prerenderStaticParams(cfg);
438
631
  // Edge SSR: render `export const ssr = true` routes to template-with-holes
439
632
  // (`_ssr/*.tmpl|slots` + the guest `Slot` module), copied into the edge host
440
- // bundle. No-op when no route opts in.
441
- await extractTemplates(cfg);
633
+ // bundle. This also rewrites the server-importable slots module against the REAL built shell
634
+ // (authoritative `HASH`). No-op when no route opts in.
635
+ const ssr = await extractTemplates(cfg, 'edge', priorServerSlots);
636
+ // If the authoritative `HASH` (or `Slot` ids) rotated since the pre-pass the server was
637
+ // compiled against, recompile the server ONCE so the guest bakes the deployed hash; otherwise
638
+ // the host rejects the response as a deploy skew. The common case (an unchanged rebuild) reuses
639
+ // the prior shell in the pre-pass, so the hashes already match and this is skipped.
640
+ if (ssr.serverSlotsChanged) {
641
+ process.stdout.write(
642
+ pc.dim(' SSR template changed; recompiling the server with the new hash…') + '\n',
643
+ );
644
+ await buildServer(cfg.root);
645
+ }
442
646
  }
443
647
 
444
648
  /**
@@ -14,7 +14,7 @@
14
14
  * ```ts
15
15
  * import { Request } from 'toiljs/server/runtime';
16
16
  * import { SlotValues } from 'toiljs/server/runtime/ssr/slots';
17
- * import { Slot, HASH } from './u_name.slots';
17
+ * import { Slot, HASH } from './_ssr/u_name.slots';
18
18
  *
19
19
  * export function renderUName(req: Request): SlotValues {
20
20
  * const v = new SlotValues(HASH);