toiljs 0.0.61 → 0.0.63

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.
@@ -137,30 +137,42 @@ export async function buildServer(root: string): Promise<void> {
137
137
  // (optimization, features, runtime) still come from the toilconfig's `release` target.
138
138
  const files = serverEntryFiles(root);
139
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.
140
+ // A project that declares a `@daemon` (L4 cold surface) and/or a `@stream` (L2/L3 stream
141
+ // surface) compiles the ONE source tree into SEPARATE artifacts, one per deployment tier, via
142
+ // one toilscript pass each; a project with only the legacy request surface keeps the
143
+ // single-artifact path (byte-identical to before). The three tiers:
144
+ // - REQUEST (L1) `server/main.ts` + `@rest`/`@service`/`@remote` -> `release.wasm`
145
+ // - STREAM (L2/L3) `server/main.stream.ts` + `@stream` -> `release-stream.wasm`
146
+ // - DAEMON (L4) `@daemon`/`@scheduled` -> `release-cold.wasm`
147
+ // toilscript's gating matrix HARD-ERRORS a class compiled under the wrong --targetMode, so each
148
+ // pass is handed only the files eligible for its tier (`@data`/`@database`/plain helpers are
149
+ // SHARED into every pass). The request pass runs LAST because it (re)writes shared/server.ts via
150
+ // --rpcModule, which the downstream client build imports.
145
151
  const split = splitSurfaceFiles(root, files);
146
- if (split.hasDaemon) {
152
+ if (split.hasDaemon || split.hasStream) {
147
153
  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, {
154
+ // DAEMON (cold) pass: --targetMode cold, no client RPC surface.
155
+ if (split.hasDaemon)
156
+ await runToilscriptPass(root, binJs, split.cold, {
157
+ mode: 'cold',
158
+ outFile: artifacts.cold,
159
+ withRpc: false,
160
+ });
161
+ // STREAM pass: --targetMode hot into its OWN `release-stream.wasm`, no client RPC surface
162
+ // (a resident stream box exposes `stream_dispatch`, not the request client surface). Driven
163
+ // by `server/main.stream.ts` + the `@stream` classes; the request box never loads it.
164
+ if (split.hasStream && split.stream.length > 0)
165
+ await runToilscriptPass(root, binJs, split.stream, {
166
+ mode: 'hot',
167
+ outFile: artifacts.stream,
168
+ withRpc: false,
169
+ });
170
+ // REQUEST pass: the L1 artifact (= the legacy `outFile`, AN-1), WITH the client RPC surface.
171
+ // A pure daemon/stream project (no request files) skips it so toilscript is not handed an
172
+ // empty entry set; the request path then stays idle (no `handle` export), correct for a
173
+ // background-only worker.
174
+ if (split.request.length > 0)
175
+ await runToilscriptPass(root, binJs, split.request, {
164
176
  mode: 'hot',
165
177
  outFile: serverWasmFile(root),
166
178
  withRpc: true,
@@ -168,7 +180,7 @@ export async function buildServer(root: string): Promise<void> {
168
180
  return;
169
181
  }
170
182
 
171
- // Legacy single-artifact path (no daemon surface): exactly today's invocation.
183
+ // Legacy single-artifact path (no daemon/stream surface): exactly today's invocation.
172
184
  await runToilscriptPass(root, binJs, files, { mode: null, outFile: null, withRpc: true });
173
185
  }
174
186
 
@@ -191,54 +203,85 @@ function resolveToilscriptBin(root: string): string {
191
203
  }
192
204
  }
193
205
 
194
- /** Files classified per target mode for the two-pass build. */
206
+ /** Files classified per deployment TIER for the multi-artifact build. */
195
207
  interface SurfaceSplit {
196
- /** Whether any file declares a `@daemon` (so a cold pass is needed at all). */
208
+ /** Whether any file declares a `@daemon` (so a cold/daemon pass is needed at all). */
197
209
  readonly hasDaemon: boolean;
198
- /** Files eligible for the COLD pass (everything except hot-only request files). */
210
+ /** Whether any file declares a `@stream` (or is a `*.stream.ts` entry), so a stream pass is needed. */
211
+ readonly hasStream: boolean;
212
+ /** Files for the DAEMON (cold) pass: `@daemon`/`@scheduled` surfaces + shared helpers. */
199
213
  readonly cold: string[];
200
- /** Files eligible for the HOT pass (everything except daemon-only cold files). */
201
- readonly hot: string[];
214
+ /** Files for the STREAM pass: `@stream` surfaces + the `*.stream.ts` entry + shared helpers. */
215
+ readonly stream: string[];
216
+ /** Files for the REQUEST pass: `@rest`/`@service`/`@remote` surfaces + the request entry + shared helpers. */
217
+ readonly request: string[];
202
218
  }
203
219
 
204
- /** A `@daemon`/`@scheduled` decorator at line start (a cold-only surface). */
220
+ /** A `@daemon`/`@scheduled` decorator at line start (the L4 cold/daemon surface). */
205
221
  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;
222
+ /** A `@stream` decorator at line start (the L2/L3 stream surface). */
223
+ const STREAM_DECORATOR = /^[ \t]*@stream\b/m;
224
+ /** A request-surface decorator at line start (`@rest`/`@route`/`@service`/`@remote`, the L1 tier). */
225
+ const REQUEST_DECORATOR = /^[ \t]*@(rest|route|service|remote)\b/m;
226
+ /** A server ENTRY re-exports the runtime WASM entry points; this marks `main.ts` / `main.stream.ts`
227
+ * (vs a plain `@data`/helper), so each entry is routed to exactly ONE tier and two entries never
228
+ * collide on a duplicate `export *` in the same pass. */
229
+ const RUNTIME_ENTRY = /from\s+['"]toiljs\/server\/runtime\/exports['"]/;
230
+
231
+ /** True for a STREAM-tier entry by the `*.stream.ts` naming convention (e.g. `main.stream.ts`). */
232
+ function isStreamEntryFile(rel: string): boolean {
233
+ return rel.endsWith('.stream.ts');
234
+ }
235
+
236
+ /** True for a COLD/daemon-tier entry by the `*.daemon.ts` naming convention (e.g. `main.daemon.ts`). */
237
+ function isDaemonEntryFile(rel: string): boolean {
238
+ return rel.endsWith('.daemon.ts');
239
+ }
208
240
 
209
241
  /**
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.
242
+ * Classify each server source file by its deployment TIER, so each toilscript pass is handed only
243
+ * the files valid for its `--targetMode` (toilscript HARD-ERRORS a class compiled under the wrong
244
+ * mode). Three tiers:
245
+ * - COLD/daemon: a file declaring `@daemon`/`@scheduled` -> `release-cold.wasm`.
246
+ * - STREAM (L2/L3): a file declaring `@stream`, OR a `*.stream.ts` entry (`main.stream.ts`) ->
247
+ * `release-stream.wasm`.
248
+ * - REQUEST (L1): a file declaring `@rest`/`@service`/`@remote`, OR a non-`*.stream.ts` runtime
249
+ * ENTRY (`main.ts`) -> `release.wasm`.
250
+ * A file with NONE of these (a plain `@data`/`@database`/helper) is SHARED into every pass, matching
251
+ * toilscript's class-level gating. Routing each entry to exactly one tier keeps `release.wasm` free
252
+ * of `stream_dispatch` and stops two entries re-exporting the runtime in the same pass.
217
253
  */
218
254
  export function splitSurfaceFiles(root: string, files: string[]): SurfaceSplit {
219
255
  let hasDaemon = false;
256
+ let hasStream = false;
220
257
  const cold: string[] = [];
221
- const hot: string[] = [];
258
+ const stream: string[] = [];
259
+ const request: string[] = [];
222
260
  for (const rel of files) {
223
261
  let src = '';
224
262
  try {
225
263
  src = fs.readFileSync(path.join(root, rel), 'utf8');
226
264
  } catch {
227
- // unreadable: keep it in both passes (let toilscript surface the error).
265
+ // unreadable: keep it in EVERY pass (let toilscript surface the error).
228
266
  cold.push(rel);
229
- hot.push(rel);
267
+ stream.push(rel);
268
+ request.push(rel);
230
269
  continue;
231
270
  }
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);
271
+ const isCold = COLD_DECORATOR.test(src) || isDaemonEntryFile(rel);
272
+ const isStream = STREAM_DECORATOR.test(src) || isStreamEntryFile(rel);
273
+ const isRequest =
274
+ REQUEST_DECORATOR.test(src) ||
275
+ (RUNTIME_ENTRY.test(src) && !isStreamEntryFile(rel) && !isDaemonEntryFile(rel));
276
+ if (isCold) hasDaemon ||= /^[ \t]*@daemon\b/m.test(src) || isDaemonEntryFile(rel);
277
+ if (isStream) hasStream = true;
278
+ // A file with no tier-specific surface is a SHARED helper, compiled into every pass.
279
+ const shared = !isCold && !isStream && !isRequest;
280
+ if (isCold || shared) cold.push(rel);
281
+ if (isStream || shared) stream.push(rel);
282
+ if (isRequest || shared) request.push(rel);
240
283
  }
241
- return { hasDaemon, cold, hot };
284
+ return { hasDaemon, hasStream, cold, stream, request };
242
285
  }
243
286
 
244
287
  interface PassOptions {
@@ -417,32 +460,40 @@ function serverWasmFile(root: string): string {
417
460
  * present in the toilconfig `release` target; otherwise derived from `outFile` by inserting the
418
461
  * mode before the extension (`release.wasm` -> `release-hot.wasm` / `release-cold.wasm`). */
419
462
  export interface ServerArtifacts {
420
- /** Absolute path to the hot (request/stream) artifact. */
463
+ /** Absolute path to the hot (request) artifact. */
421
464
  readonly hot: string;
422
465
  /** Absolute path to the cold (daemon) artifact. */
423
466
  readonly cold: string;
467
+ /** Absolute path to the stream (L2/L3 `@stream`) artifact (`release-stream.wasm`). */
468
+ readonly stream: string;
424
469
  }
425
470
  export function serverArtifacts(root: string): ServerArtifacts {
426
471
  let out = 'build/server/release.wasm';
427
472
  let hot: string | undefined;
428
473
  let cold: string | undefined;
474
+ let stream: string | undefined;
429
475
  try {
430
476
  const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
431
- targets?: Record<string, { outFile?: string; hotFile?: string; coldFile?: string }>;
477
+ targets?: Record<
478
+ string,
479
+ { outFile?: string; hotFile?: string; coldFile?: string; streamFile?: string }
480
+ >;
432
481
  };
433
482
  out = cfg.targets?.release?.outFile ?? out;
434
483
  hot = cfg.targets?.release?.hotFile;
435
484
  cold = cfg.targets?.release?.coldFile;
485
+ stream = cfg.targets?.release?.streamFile;
436
486
  } catch {
437
487
  // No readable toilconfig: caller already gated on its existence; keep defaults.
438
488
  }
439
- const ins = (mode: 'hot' | 'cold'): string => {
489
+ const ins = (mode: 'hot' | 'cold' | 'stream'): string => {
440
490
  const ext = path.extname(out);
441
491
  return out.slice(0, ext ? -ext.length : undefined) + '-' + mode + (ext || '.wasm');
442
492
  };
443
493
  return {
444
494
  hot: path.resolve(root, hot ?? ins('hot')),
445
495
  cold: path.resolve(root, cold ?? ins('cold')),
496
+ stream: path.resolve(root, stream ?? ins('stream')),
446
497
  };
447
498
  }
448
499
 
@@ -29,8 +29,8 @@ import fs from 'node:fs';
29
29
  import { createRequire } from 'node:module';
30
30
  import path from 'node:path';
31
31
 
32
- import { type ComponentType, type Context, createElement, type ReactNode } from 'react';
33
- import { renderToStaticMarkup } from 'react-dom/server';
32
+ import { type ComponentType, type Context, createElement, type ReactNode, Suspense } from 'react';
33
+ import { renderToString } from 'react-dom/server';
34
34
  import { createServer } from 'vite';
35
35
 
36
36
  import { type ResolvedToilConfig } from './config.js';
@@ -71,8 +71,13 @@ export interface RouteRenderInput {
71
71
  * React copies leaves the hook dispatcher null ("Cannot read properties of
72
72
  * null (reading 'useRef')"). */
73
73
  createElement?: typeof createElement;
74
- /** `renderToStaticMarkup` paired with {@link createElement}'s React. */
75
- 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;
78
+ /** React's `Suspense` from the SAME instance as {@link createElement}, so the
79
+ * Suspense dehydration markers (`<!--$-->`) emitted match the client's. */
80
+ Suspense?: typeof Suspense;
76
81
  }
77
82
 
78
83
  export interface TemplateArtifacts {
@@ -86,22 +91,29 @@ export interface TemplateArtifacts {
86
91
  slotCount: number;
87
92
  }
88
93
 
89
- /** Build the route element tree: layouts (outermost first) wrapping the page,
90
- * under the loader-data provider. The Suspense/RoutePage wrappers the client
91
- * adds contribute no DOM, so this reproduces the client's markup. */
94
+ /** Build the route element tree, mirroring the client Router so `renderToString`
95
+ * emits the SAME Suspense dehydration markers (`<!--$-->`) `hydrateRoot` expects.
96
+ * The page sits under the loader-data provider inside a route `Suspense`, and EACH
97
+ * layout (outermost first) gets its own `Suspense`, exactly as `renderMatched` +
98
+ * `Router` wrap them. Without these markers the client's Suspense boundaries have
99
+ * nothing to align to and hydration regenerates the whole tree. (The ErrorBoundary
100
+ * / context wrappers the client adds emit no DOM and no markers, so they are
101
+ * omitted here.) The `Suspense` component must come from the SAME React as `h`. */
92
102
  export function assembleRouteElement(
93
103
  Page: ComponentType,
94
104
  layouts: ComponentType<{ children?: ReactNode }>[],
95
105
  loaderData: unknown,
96
106
  loaderContext: Context<unknown> | null,
97
107
  h: typeof createElement = createElement,
108
+ SuspenseComp: typeof Suspense = Suspense,
98
109
  ): ReactNode {
99
110
  let node: ReactNode = h(Page);
100
111
  if (loaderContext) {
101
112
  node = h(loaderContext.Provider, { value: loaderData }, node);
102
113
  }
114
+ node = h(SuspenseComp, { fallback: null }, node); // route Suspense (mirrors RoutePage's boundary)
103
115
  for (let i = layouts.length - 1; i >= 0; i--) {
104
- node = h(layouts[i], null, node);
116
+ node = h(SuspenseComp, { fallback: null }, h(layouts[i], null, node));
105
117
  }
106
118
  return node;
107
119
  }
@@ -115,16 +127,33 @@ export function injectIntoShell(shell: string, routeHtml: string): string {
115
127
  return shell.replace(ROOT_DIV, `<div id="root">${routeHtml}</div>${SSR_MARKER}`);
116
128
  }
117
129
 
130
+ /**
131
+ * React 19 auto-emits hoistable resource tags into `<head>` on the client (it
132
+ * adds a `<link rel="preload">` for an `<img>`, and hoists `<title>` / `<meta>`),
133
+ * but the string renderer emits them INLINE in the route fragment. Left in the
134
+ * spliced `#root` template they would not appear in the client's hydrated `#root`
135
+ * (the client puts them in `<head>`), so `hydrateRoot` reports a mismatch. Strip
136
+ * them from the route fragment; the shell already carries the document head, and
137
+ * the client re-adds its own resource hints. Only the fragment is stripped. */
138
+ function stripHoistedResourceTags(html: string): string {
139
+ return html
140
+ .replace(/<link\b[^>]*>/gi, '')
141
+ .replace(/<meta\b[^>]*>/gi, '')
142
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '');
143
+ }
144
+
118
145
  /** Render one route to its template artifacts (pure given its inputs). */
119
146
  export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts {
120
147
  const h = input.createElement ?? createElement;
121
- const render = input.renderToStaticMarkup ?? renderToStaticMarkup;
148
+ const render = input.renderToString ?? renderToString;
149
+ const SuspenseComp = input.Suspense ?? Suspense;
122
150
  const element = assembleRouteElement(
123
151
  input.Page,
124
152
  input.layouts,
125
153
  input.loaderData,
126
154
  input.loaderContext,
127
155
  h,
156
+ SuspenseComp,
128
157
  );
129
158
  input.setSsrBuild(true);
130
159
  let routeHtml: string;
@@ -133,7 +162,7 @@ export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts
133
162
  } finally {
134
163
  input.setSsrBuild(false);
135
164
  }
136
- const full = injectIntoShell(input.shell, routeHtml);
165
+ const full = injectIntoShell(input.shell, stripHoistedResourceTags(routeHtml));
137
166
  const extracted: Extracted = extractFromHtml(full);
138
167
  const ids = assignSlotIds(extracted.slots);
139
168
  const hash = coherenceHash(extracted.tmpl, extracted.slots);
@@ -242,6 +271,7 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
242
271
 
243
272
  const client = (await server.ssrLoadModule('toiljs/client')) as unknown as {
244
273
  __setSsrBuild: (on: boolean) => void;
274
+ __setSsrLocation: (path: string | null) => void;
245
275
  LoaderDataContext: Context<unknown>;
246
276
  };
247
277
 
@@ -262,9 +292,12 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
262
292
  // (`useLocation` -> `useRef`) throws. (`ssrLoadModule('react')` can't be used:
263
293
  // Vite's SSR runner cannot evaluate the CJS module -> "module is not defined".)
264
294
  const appRequire = createRequire(path.join(cfg.root, 'package.json'));
265
- const react = appRequire('react') as { createElement: typeof createElement };
295
+ const react = appRequire('react') as {
296
+ createElement: typeof createElement;
297
+ Suspense: typeof Suspense;
298
+ };
266
299
  const reactDomServer = appRequire('react-dom/server') as {
267
- renderToStaticMarkup: typeof renderToStaticMarkup;
300
+ renderToString: typeof renderToString;
268
301
  };
269
302
 
270
303
  const rendered: RenderedRoute[] = [];
@@ -286,8 +319,9 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
286
319
  ? await mod.loader({ params, searchParams: new URLSearchParams() })
287
320
  : undefined;
288
321
 
322
+ const rootLayout = findLayout(cfg);
289
323
  const layoutFiles = [
290
- ...(findLayout(cfg) ? [findLayout(cfg)!] : []),
324
+ ...(rootLayout ? [rootLayout] : []),
291
325
  ...findSpecialChain(cfg, r.file, 'layout', false),
292
326
  ];
293
327
  const layouts: ComponentType<{ children?: ReactNode }>[] = [];
@@ -299,6 +333,10 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
299
333
  }
300
334
 
301
335
  const name = routeTemplateName(r.pattern);
336
+ // Tell location hooks which URL this template is for, so a NavLink's active
337
+ // class / aria-current render as they will on the client at this route (else
338
+ // the `/` default mismatches on hydration). Cleared in `finally`.
339
+ client.__setSsrLocation(samplePath(r.pattern));
302
340
  const art = extractRouteTemplate({
303
341
  name,
304
342
  Page: mod.default,
@@ -308,7 +346,8 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
308
346
  setSsrBuild: client.__setSsrBuild,
309
347
  shell,
310
348
  createElement: react.createElement,
311
- renderToStaticMarkup: reactDomServer.renderToStaticMarkup,
349
+ renderToString: reactDomServer.renderToString,
350
+ Suspense: react.Suspense,
312
351
  });
313
352
  rendered.push({ pattern: r.pattern, art });
314
353
  } catch (err) {
@@ -317,6 +356,8 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
317
356
  err instanceof Error ? err.message : String(err)
318
357
  }) — falls back to client rendering`,
319
358
  );
359
+ } finally {
360
+ client.__setSsrLocation(null);
320
361
  }
321
362
  }
322
363
  } finally {
@@ -423,6 +464,13 @@ function sampleParams(pattern: string): Record<string, string> {
423
464
  return params;
424
465
  }
425
466
 
467
+ /** The concrete pathname for a route pattern, dynamic segments filled with the same `sample`
468
+ * value {@link sampleParams} uses, so location-dependent markup renders consistently. Static
469
+ * routes return their pattern unchanged. */
470
+ function samplePath(pattern: string): string {
471
+ return pattern.replace(/[:*]+([A-Za-z0-9_]+)/g, 'sample');
472
+ }
473
+
426
474
  interface RouteModule {
427
475
  default: ComponentType;
428
476
  ssr?: boolean;
@@ -84,9 +84,10 @@ describe('serverArtifacts path derivation', () => {
84
84
  const a = serverArtifacts(tmp);
85
85
  expect(a.hot).toBe(join(tmp, 'build/server/release-hot.wasm'));
86
86
  expect(a.cold).toBe(join(tmp, 'build/server/release-cold.wasm'));
87
+ expect(a.stream).toBe(join(tmp, 'build/server/release-stream.wasm'));
87
88
  });
88
89
 
89
- it('honors explicit hotFile/coldFile when present', () => {
90
+ it('honors explicit hotFile/coldFile/streamFile when present', () => {
90
91
  writeFileSync(
91
92
  join(tmp, 'toilconfig.json'),
92
93
  JSON.stringify({
@@ -95,6 +96,7 @@ describe('serverArtifacts path derivation', () => {
95
96
  outFile: 'build/server/release.wasm',
96
97
  hotFile: 'out/hot.wasm',
97
98
  coldFile: 'out/cold.wasm',
99
+ streamFile: 'out/stream.wasm',
98
100
  },
99
101
  },
100
102
  }),
@@ -102,6 +104,7 @@ describe('serverArtifacts path derivation', () => {
102
104
  const a = serverArtifacts(tmp);
103
105
  expect(a.hot).toBe(join(tmp, 'out/hot.wasm'));
104
106
  expect(a.cold).toBe(join(tmp, 'out/cold.wasm'));
107
+ expect(a.stream).toBe(join(tmp, 'out/stream.wasm'));
105
108
  });
106
109
  });
107
110
 
@@ -131,30 +134,46 @@ describe('splitSurfaceFiles per-pass classification', () => {
131
134
  return rels;
132
135
  }
133
136
 
134
- it('drops daemon-only files from the hot pass and hot-only files from the cold pass', () => {
137
+ it('routes each surface to its tier (request/stream/cold) and shares plain helpers', () => {
135
138
  const rels = lay({
136
139
  'server/jobs.ts': '@daemon\nclass J { @scheduled("1s") t(): void {} }\n',
137
140
  'server/api.ts': '@rest\nclass A {}\n',
141
+ 'server/chat.ts': "@stream('chat')\nclass C {}\n",
138
142
  'server/model.ts': '@data\nclass M {}\n',
139
143
  'server/util.ts': 'export function helper(): i32 { return 1; }\n',
140
144
  });
141
145
  const split = splitSurfaceFiles(tmp, rels);
142
146
  expect(split.hasDaemon).toBe(true);
143
- // hot pass: everything except the daemon-only jobs.ts.
144
- expect(split.hot.sort()).toEqual(
145
- ['server/api.ts', 'server/model.ts', 'server/util.ts'].sort(),
146
- );
147
- // cold pass: everything except the hot-only api.ts.
148
- expect(split.cold.sort()).toEqual(
149
- ['server/jobs.ts', 'server/model.ts', 'server/util.ts'].sort(),
150
- );
147
+ expect(split.hasStream).toBe(true);
148
+ const shared = ['server/model.ts', 'server/util.ts'];
149
+ // Each surface goes to ONLY its tier; the @data/helper files are shared into all three.
150
+ expect(split.cold.sort()).toEqual(['server/jobs.ts', ...shared].sort());
151
+ expect(split.stream.sort()).toEqual(['server/chat.ts', ...shared].sort());
152
+ expect(split.request.sort()).toEqual(['server/api.ts', ...shared].sort());
153
+ });
154
+
155
+ it('routes entries by the *.stream.ts / *.daemon.ts naming and the runtime-export request entry', () => {
156
+ const RT = "export * from 'toiljs/server/runtime/exports';\n";
157
+ const rels = lay({
158
+ 'server/main.ts': RT, // runtime entry, not *.stream/.daemon -> request
159
+ 'server/main.stream.ts': RT, // *.stream.ts -> stream (not request, despite the runtime export)
160
+ 'server/main.daemon.ts': "import './daemon/Jobs';\n", // *.daemon.ts -> cold
161
+ });
162
+ const split = splitSurfaceFiles(tmp, rels);
163
+ expect(split.hasStream).toBe(true);
164
+ expect(split.hasDaemon).toBe(true);
165
+ // Each entry is routed to EXACTLY one tier (so two entries never collide on `export *`).
166
+ expect(split.request).toEqual(['server/main.ts']);
167
+ expect(split.stream).toEqual(['server/main.stream.ts']);
168
+ expect(split.cold).toEqual(['server/main.daemon.ts']);
151
169
  });
152
170
 
153
- it('keeps a file that mixes both surfaces in both passes', () => {
171
+ it('keeps a file that mixes daemon + request surfaces in both of those passes (not stream)', () => {
154
172
  const rels = lay({ 'server/both.ts': '@daemon\nclass J {}\n@rest\nclass A {}\n' });
155
173
  const split = splitSurfaceFiles(tmp, rels);
156
- expect(split.hot).toContain('server/both.ts');
157
174
  expect(split.cold).toContain('server/both.ts');
175
+ expect(split.request).toContain('server/both.ts');
176
+ expect(split.stream).not.toContain('server/both.ts');
158
177
  });
159
178
  });
160
179
 
@@ -0,0 +1,122 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Real-hydration test: drive the actual build (`extractRouteTemplate`, which now
4
+ * renders with `renderToString`) -> splice -> `hydrateRoot` path in jsdom and
5
+ * assert there is NO hydration mismatch and the content is present. This is the
6
+ * failure users hit ("server rendered HTML didn't match the client"), and it
7
+ * covers the things that broke it:
8
+ * - "text + hole + text" (`Hello, <Hole>{name}</Hole>!`): needs the `<!-- -->`
9
+ * text-boundary markers `renderToString` emits.
10
+ * - an `<img>`, whose React 19 auto-preload `<link>` must be kept OUT of `#root`.
11
+ * - an `<Island>`, which must be empty on the first (hydration) render.
12
+ * - the Suspense dehydration markers (`<!--$-->`): `assembleRouteElement` wraps the
13
+ * route (and each layout) in `Suspense`, so we hydrate through the SAME `Suspense`
14
+ * structure the client Router renders. Remove that wrapping and this test fails
15
+ * with "server rendered HTML didn't match the client", the exact regression.
16
+ */
17
+ import { act, Suspense } from 'react';
18
+ import { hydrateRoot } from 'react-dom/client';
19
+ import { describe, expect, it, vi } from 'vitest';
20
+
21
+ import { Hole, Island, RawHtml, __setSsrBuild } from '../src/client/ssr/markers';
22
+ import { extractRouteTemplate } from '../src/compiler/template-build';
23
+ import { reactEscapeHtml, spliceTemplate } from '../src/compiler/template';
24
+
25
+ const NAME = 'world';
26
+ const BLURB = 'Rendered at the <strong>edge</strong>.';
27
+
28
+ function Page(): React.ReactElement {
29
+ return (
30
+ <main>
31
+ <img src="/images/logo.svg" alt="logo" width={28} height={28} />
32
+ <h1>
33
+ Hello, <Hole id="name">{NAME}</Hole>!
34
+ </h1>
35
+ <p>
36
+ <RawHtml id="blurb" html={BLURB} as="span" />
37
+ </p>
38
+ <Island>
39
+ <span className="isle">island-only</span>
40
+ </Island>
41
+ </main>
42
+ );
43
+ }
44
+
45
+ const SHELL =
46
+ '<!doctype html><html><head><title>t</title></head><body><div id="root"></div></body></html>';
47
+
48
+ /** Build the template (renderToString + strip), splice per-slot values, return #root inner HTML. */
49
+ function serverRootHtml(): string {
50
+ const art = extractRouteTemplate({
51
+ name: 'hyd',
52
+ Page,
53
+ layouts: [],
54
+ loaderData: null,
55
+ loaderContext: null,
56
+ setSsrBuild: __setSsrBuild,
57
+ shell: SHELL,
58
+ });
59
+ const valueFor: Record<number, Buffer> = {
60
+ 0: Buffer.from(reactEscapeHtml(NAME), 'utf8'), // name (text)
61
+ 1: Buffer.from(BLURB, 'utf8'), // blurb (raw)
62
+ };
63
+ const nSlots = art.slotsBin.readUInt16LE(44);
64
+ const inserts: { offset: number; value: Buffer }[] = [];
65
+ let o = 46;
66
+ for (let i = 0; i < nSlots; i++) {
67
+ inserts.push({ offset: art.slotsBin.readUInt32LE(o), value: valueFor[art.slotsBin.readUInt16LE(o + 4)] });
68
+ o += 8;
69
+ }
70
+ const full = spliceTemplate(art.tmpl, inserts).toString('utf8');
71
+ const m = /<div id="root">([\s\S]*?)<\/div><template id="__toil_ssr">/.exec(full);
72
+ if (!m) throw new Error('could not isolate #root');
73
+ return m[1];
74
+ }
75
+
76
+ describe('ssr hydration (real hydrateRoot, no mismatch)', () => {
77
+ it('hydrates the spliced server markup cleanly and reveals the island after mount', async () => {
78
+ const rootInner = serverRootHtml();
79
+ expect(rootInner).not.toContain('rel="preload"'); // preload hoisted to <head>, not #root
80
+ expect(rootInner).not.toContain('island-only'); // island empty server-side
81
+ expect(rootInner).toContain('Hello, '); // text hole filled
82
+ expect(rootInner).toContain('<strong>edge</strong>'); // raw hole verbatim
83
+
84
+ document.body.innerHTML = `<div id="root">${rootInner}</div>`;
85
+ const rootEl = document.getElementById('root')!;
86
+
87
+ const recoverable: string[] = [];
88
+ const consoleErrors: string[] = [];
89
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => {
90
+ consoleErrors.push(a.map(String).join(' '));
91
+ });
92
+ try {
93
+ await act(async () => {
94
+ // Hydrate through the route Suspense `assembleRouteElement` emits (layouts: []
95
+ // here -> just the route boundary), so the client's Suspense markers line up
96
+ // with the server's. This is what the real client Router does.
97
+ hydrateRoot(
98
+ rootEl,
99
+ (
100
+ <Suspense fallback={null}>
101
+ <Page />
102
+ </Suspense>
103
+ ),
104
+ {
105
+ onRecoverableError: (e) => recoverable.push(String(e)),
106
+ },
107
+ );
108
+ });
109
+ } finally {
110
+ spy.mockRestore();
111
+ }
112
+
113
+ const noise = /hydrat|did not match|server rendered|didn't match/i;
114
+ expect(recoverable.filter((e) => noise.test(e))).toEqual([]);
115
+ expect(consoleErrors.filter((e) => noise.test(e))).toEqual([]);
116
+
117
+ // Content survived hydration (not regenerated/blanked); the island revealed after mount.
118
+ expect(rootEl.querySelector('h1')?.textContent).toBe('Hello, world!');
119
+ expect(rootEl.querySelector('.isle')?.textContent).toBe('island-only');
120
+ expect(rootEl.querySelector('img')?.getAttribute('src')).toBe('/images/logo.svg');
121
+ });
122
+ });
@@ -172,9 +172,11 @@ describe.skipIf(!built)('edge SSR guest render (real single-wasm build)', () =>
172
172
  }
173
173
  const out = spliceTemplate(tmpl, inserts).toString('utf8');
174
174
 
175
- // The spliced section is well-formed and carries every filled hole.
175
+ // The spliced section is well-formed and carries every filled hole. The
176
+ // `<!-- -->` around `world` are React's text-boundary markers (renderToString
177
+ // emits them so hydrateRoot can align the `name` hole between "Hello, " and "!").
176
178
  expect(out).toContain(
177
- '<section class="hello"><h1>Hello, world!</h1>' +
179
+ '<section class="hello"><h1>Hello, <!-- -->world<!-- -->!</h1>' +
178
180
  '<p class="hello-blurb"><span>Rendered at the <strong>edge</strong> ' +
179
181
  'from a tiny values envelope.</span></p>' +
180
182
  '<h2>Service snapshot</h2>' +