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.
- package/CHANGELOG.md +22 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +1 -1
- package/build/client/routing/hooks.d.ts +1 -0
- package/build/client/routing/hooks.js +7 -1
- package/build/client/routing/mount.js +0 -26
- package/build/client/ssr/markers.js +7 -3
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.d.ts +4 -1
- package/build/compiler/index.js +46 -18
- package/build/compiler/template-build.d.ts +5 -4
- package/build/compiler/template-build.js +26 -9
- package/examples/basic/server/services/Stats.ts +2 -3
- package/examples/basic/server/services/remotes.ts +2 -2
- package/package.json +1 -1
- package/src/client/index.ts +1 -0
- package/src/client/routing/hooks.ts +16 -3
- package/src/client/routing/mount.tsx +8 -36
- package/src/client/ssr/markers.tsx +15 -5
- package/src/compiler/index.ts +104 -53
- package/src/compiler/template-build.ts +62 -14
- package/test/daemon-build.test.ts +31 -12
- package/test/ssr-hydration.test.tsx +122 -0
- package/test/ssr-render.test.ts +4 -2
- package/test/ssr-template.test.tsx +5 -1
- package/examples/basic/server/streams/Echo.ts +0 -49
package/src/compiler/index.ts
CHANGED
|
@@ -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)
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
201
|
-
readonly
|
|
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 (
|
|
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
|
|
207
|
-
const
|
|
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
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|
|
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
|
|
265
|
+
// unreadable: keep it in EVERY pass (let toilscript surface the error).
|
|
228
266
|
cold.push(rel);
|
|
229
|
-
|
|
267
|
+
stream.push(rel);
|
|
268
|
+
request.push(rel);
|
|
230
269
|
continue;
|
|
231
270
|
}
|
|
232
|
-
const isCold = COLD_DECORATOR.test(src);
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
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,
|
|
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
|
|
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<
|
|
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 {
|
|
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
|
-
/** `
|
|
75
|
-
|
|
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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
expect(split.
|
|
149
|
-
|
|
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
|
|
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
|
+
});
|
package/test/ssr-render.test.ts
CHANGED
|
@@ -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
|
|
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>' +
|