toiljs 0.0.62 → 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 +10 -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/ssr/markers.js +1 -1
- 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 +3 -2
- package/build/compiler/template-build.js +16 -5
- 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/ssr/markers.tsx +4 -1
- package/src/compiler/index.ts +104 -53
- package/src/compiler/template-build.ts +38 -7
- package/test/daemon-build.test.ts +31 -12
- package/test/ssr-hydration.test.tsx +20 -5
- package/test/ssr-template.test.tsx +5 -3
- package/examples/basic/server/streams/Echo.ts +0 -49
|
@@ -29,7 +29,7 @@ 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';
|
|
32
|
+
import { type ComponentType, type Context, createElement, type ReactNode, Suspense } from 'react';
|
|
33
33
|
import { renderToString } from 'react-dom/server';
|
|
34
34
|
import { createServer } from 'vite';
|
|
35
35
|
|
|
@@ -75,6 +75,9 @@ export interface RouteRenderInput {
|
|
|
75
75
|
* `renderToStaticMarkup`) because hydration needs the `<!-- -->` text-node
|
|
76
76
|
* boundary markers it emits, so `hydrateRoot` can align "text + hole" runs. */
|
|
77
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;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
export interface TemplateArtifacts {
|
|
@@ -88,22 +91,29 @@ export interface TemplateArtifacts {
|
|
|
88
91
|
slotCount: number;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
/** Build the route element tree
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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`. */
|
|
94
102
|
export function assembleRouteElement(
|
|
95
103
|
Page: ComponentType,
|
|
96
104
|
layouts: ComponentType<{ children?: ReactNode }>[],
|
|
97
105
|
loaderData: unknown,
|
|
98
106
|
loaderContext: Context<unknown> | null,
|
|
99
107
|
h: typeof createElement = createElement,
|
|
108
|
+
SuspenseComp: typeof Suspense = Suspense,
|
|
100
109
|
): ReactNode {
|
|
101
110
|
let node: ReactNode = h(Page);
|
|
102
111
|
if (loaderContext) {
|
|
103
112
|
node = h(loaderContext.Provider, { value: loaderData }, node);
|
|
104
113
|
}
|
|
114
|
+
node = h(SuspenseComp, { fallback: null }, node); // route Suspense (mirrors RoutePage's boundary)
|
|
105
115
|
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
106
|
-
node = h(layouts[i], null, node);
|
|
116
|
+
node = h(SuspenseComp, { fallback: null }, h(layouts[i], null, node));
|
|
107
117
|
}
|
|
108
118
|
return node;
|
|
109
119
|
}
|
|
@@ -136,12 +146,14 @@ function stripHoistedResourceTags(html: string): string {
|
|
|
136
146
|
export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts {
|
|
137
147
|
const h = input.createElement ?? createElement;
|
|
138
148
|
const render = input.renderToString ?? renderToString;
|
|
149
|
+
const SuspenseComp = input.Suspense ?? Suspense;
|
|
139
150
|
const element = assembleRouteElement(
|
|
140
151
|
input.Page,
|
|
141
152
|
input.layouts,
|
|
142
153
|
input.loaderData,
|
|
143
154
|
input.loaderContext,
|
|
144
155
|
h,
|
|
156
|
+
SuspenseComp,
|
|
145
157
|
);
|
|
146
158
|
input.setSsrBuild(true);
|
|
147
159
|
let routeHtml: string;
|
|
@@ -259,6 +271,7 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
|
|
|
259
271
|
|
|
260
272
|
const client = (await server.ssrLoadModule('toiljs/client')) as unknown as {
|
|
261
273
|
__setSsrBuild: (on: boolean) => void;
|
|
274
|
+
__setSsrLocation: (path: string | null) => void;
|
|
262
275
|
LoaderDataContext: Context<unknown>;
|
|
263
276
|
};
|
|
264
277
|
|
|
@@ -279,7 +292,10 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
|
|
|
279
292
|
// (`useLocation` -> `useRef`) throws. (`ssrLoadModule('react')` can't be used:
|
|
280
293
|
// Vite's SSR runner cannot evaluate the CJS module -> "module is not defined".)
|
|
281
294
|
const appRequire = createRequire(path.join(cfg.root, 'package.json'));
|
|
282
|
-
const react = appRequire('react') as {
|
|
295
|
+
const react = appRequire('react') as {
|
|
296
|
+
createElement: typeof createElement;
|
|
297
|
+
Suspense: typeof Suspense;
|
|
298
|
+
};
|
|
283
299
|
const reactDomServer = appRequire('react-dom/server') as {
|
|
284
300
|
renderToString: typeof renderToString;
|
|
285
301
|
};
|
|
@@ -303,8 +319,9 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
|
|
|
303
319
|
? await mod.loader({ params, searchParams: new URLSearchParams() })
|
|
304
320
|
: undefined;
|
|
305
321
|
|
|
322
|
+
const rootLayout = findLayout(cfg);
|
|
306
323
|
const layoutFiles = [
|
|
307
|
-
...(
|
|
324
|
+
...(rootLayout ? [rootLayout] : []),
|
|
308
325
|
...findSpecialChain(cfg, r.file, 'layout', false),
|
|
309
326
|
];
|
|
310
327
|
const layouts: ComponentType<{ children?: ReactNode }>[] = [];
|
|
@@ -316,6 +333,10 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
|
|
|
316
333
|
}
|
|
317
334
|
|
|
318
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));
|
|
319
340
|
const art = extractRouteTemplate({
|
|
320
341
|
name,
|
|
321
342
|
Page: mod.default,
|
|
@@ -326,6 +347,7 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
|
|
|
326
347
|
shell,
|
|
327
348
|
createElement: react.createElement,
|
|
328
349
|
renderToString: reactDomServer.renderToString,
|
|
350
|
+
Suspense: react.Suspense,
|
|
329
351
|
});
|
|
330
352
|
rendered.push({ pattern: r.pattern, art });
|
|
331
353
|
} catch (err) {
|
|
@@ -334,6 +356,8 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
|
|
|
334
356
|
err instanceof Error ? err.message : String(err)
|
|
335
357
|
}) — falls back to client rendering`,
|
|
336
358
|
);
|
|
359
|
+
} finally {
|
|
360
|
+
client.__setSsrLocation(null);
|
|
337
361
|
}
|
|
338
362
|
}
|
|
339
363
|
} finally {
|
|
@@ -440,6 +464,13 @@ function sampleParams(pattern: string): Record<string, string> {
|
|
|
440
464
|
return params;
|
|
441
465
|
}
|
|
442
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
|
+
|
|
443
474
|
interface RouteModule {
|
|
444
475
|
default: ComponentType;
|
|
445
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
|
|
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
* renders with `renderToString`) -> splice -> `hydrateRoot` path in jsdom and
|
|
5
5
|
* assert there is NO hydration mismatch and the content is present. This is the
|
|
6
6
|
* failure users hit ("server rendered HTML didn't match the client"), and it
|
|
7
|
-
* covers the
|
|
7
|
+
* covers the things that broke it:
|
|
8
8
|
* - "text + hole + text" (`Hello, <Hole>{name}</Hole>!`): needs the `<!-- -->`
|
|
9
9
|
* text-boundary markers `renderToString` emits.
|
|
10
10
|
* - an `<img>`, whose React 19 auto-preload `<link>` must be kept OUT of `#root`.
|
|
11
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.
|
|
12
16
|
*/
|
|
13
|
-
import { act } from 'react';
|
|
17
|
+
import { act, Suspense } from 'react';
|
|
14
18
|
import { hydrateRoot } from 'react-dom/client';
|
|
15
19
|
import { describe, expect, it, vi } from 'vitest';
|
|
16
20
|
|
|
@@ -87,9 +91,20 @@ describe('ssr hydration (real hydrateRoot, no mismatch)', () => {
|
|
|
87
91
|
});
|
|
88
92
|
try {
|
|
89
93
|
await act(async () => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
);
|
|
93
108
|
});
|
|
94
109
|
} finally {
|
|
95
110
|
spy.mockRestore();
|
|
@@ -356,10 +356,12 @@ describe('ssr build orchestration', () => {
|
|
|
356
356
|
|
|
357
357
|
const tmpl = art.tmpl.toString('utf8');
|
|
358
358
|
// Full document with the layout + page scaffold spliced into #root, holes removed.
|
|
359
|
-
//
|
|
360
|
-
//
|
|
359
|
+
// `<!--$-->` / `<!--/$-->` are the Suspense dehydration markers `assembleRouteElement`
|
|
360
|
+
// emits by wrapping each layout AND the route in `Suspense` (mirroring the client
|
|
361
|
+
// Router), so `hydrateRoot` can align its Suspense boundaries. The `<!-- -->` after `@`
|
|
362
|
+
// is React's text-boundary marker for the `username` hole; the hole text is stripped.
|
|
361
363
|
expect(tmpl).toContain(
|
|
362
|
-
'<div id="root"
|
|
364
|
+
'<div id="root"><!--$--><div class="app"><!--$--><main><h1>@<!-- --></h1><div></div><ul></ul></main><!--/$--></div><!--/$--></div>',
|
|
363
365
|
);
|
|
364
366
|
expect(tmpl).toContain('<template id="__toil_ssr"></template>');
|
|
365
367
|
expect(tmpl).toContain('/assets/app-abc123.js'); // bootstrap script preserved
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A `@stream` protocol handler mounted at `/echo`, running as a RESIDENT wasm box
|
|
3
|
-
* per WebTransport connection on the Toil edge - distributed across the eligible
|
|
4
|
-
* L2/L3 nodes and pinned to ONE worker for the connection's lifetime via QUIC
|
|
5
|
-
* connection-id steering.
|
|
6
|
-
*
|
|
7
|
-
* The defining property of a `@stream` (vs a `@rest` handler): the box is
|
|
8
|
-
* RESIDENT, so instance state PERSISTS across events on the same connection. Here
|
|
9
|
-
* `count` survives every `@message` because the box is never reset between events
|
|
10
|
-
* - unlike a `@rest` handler, which is fresh per request. On the client:
|
|
11
|
-
*
|
|
12
|
-
* const stream = await Server.STREAM.echo.connect();
|
|
13
|
-
* stream.send(new TextEncoder().encode('hi'));
|
|
14
|
-
*
|
|
15
|
-
* Lifecycle hooks: `@connect` (open), `@message` (an inbound frame), `@close`
|
|
16
|
-
* (graceful close), `@disconnect` (abrupt transport loss).
|
|
17
|
-
*
|
|
18
|
-
* NOTE: reading the inbound frame and replying is the NEXT increment (the
|
|
19
|
-
* `StreamPacket` / `StreamOutbound` message bridge). The intended shape is:
|
|
20
|
-
*
|
|
21
|
-
* @message reply(packet: StreamPacket): StreamOutbound {
|
|
22
|
-
* return StreamOutbound.reply(packet.bytes()); // echo the bytes back
|
|
23
|
-
* }
|
|
24
|
-
*
|
|
25
|
-
* Until that lands, the hooks run on the connection lifecycle; this example counts
|
|
26
|
-
* frames to demonstrate that the resident box keeps state across them.
|
|
27
|
-
*/
|
|
28
|
-
@stream('echo')
|
|
29
|
-
class Echo {
|
|
30
|
-
// Resident per-connection state: survives across events (ResetMode::None).
|
|
31
|
-
private count: i32 = 0;
|
|
32
|
-
|
|
33
|
-
@connect
|
|
34
|
-
onConnect(): void {
|
|
35
|
-
// A fresh connection: its dedicated box starts the counter at 0.
|
|
36
|
-
this.count = 0;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
@message
|
|
40
|
-
onMessage(): void {
|
|
41
|
-
// Persists across frames because the box is resident, not reset per event.
|
|
42
|
-
this.count = this.count + 1;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
@close
|
|
46
|
-
onClose(): void {
|
|
47
|
-
// Graceful close: the per-connection box is torn down after this hook.
|
|
48
|
-
}
|
|
49
|
-
}
|