toiljs 0.0.34 → 0.0.36

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 (110) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -0
  3. package/as-pect.config.js +8 -2
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +97 -0
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.d.ts +42 -0
  8. package/build/client/auth.js +179 -0
  9. package/build/client/index.d.ts +5 -1
  10. package/build/client/index.js +3 -1
  11. package/build/client/routing/loader.d.ts +1 -0
  12. package/build/client/routing/loader.js +37 -0
  13. package/build/client/routing/mount.js +32 -1
  14. package/build/client/ssr/markers.d.ts +34 -0
  15. package/build/client/ssr/markers.js +49 -0
  16. package/build/compiler/.tsbuildinfo +1 -1
  17. package/build/compiler/docs.js +88 -1
  18. package/build/compiler/generate.d.ts +2 -0
  19. package/build/compiler/generate.js +2 -2
  20. package/build/compiler/index.js +2 -0
  21. package/build/compiler/ssr-codegen.d.ts +2 -0
  22. package/build/compiler/ssr-codegen.js +36 -0
  23. package/build/compiler/template-build.d.ts +29 -0
  24. package/build/compiler/template-build.js +150 -0
  25. package/build/compiler/template.d.ts +22 -0
  26. package/build/compiler/template.js +169 -0
  27. package/build/devserver/.tsbuildinfo +1 -1
  28. package/build/devserver/crypto.js +15 -0
  29. package/build/devserver/host.js +1 -0
  30. package/build/devserver/module.d.ts +1 -0
  31. package/build/devserver/module.js +23 -1
  32. package/docs/README.md +56 -0
  33. package/docs/auth.md +261 -0
  34. package/docs/caching.md +115 -0
  35. package/docs/cookies.md +457 -0
  36. package/docs/crypto.md +130 -0
  37. package/docs/data.md +131 -0
  38. package/docs/getting-started.md +128 -0
  39. package/docs/routing.md +259 -0
  40. package/docs/rpc.md +149 -0
  41. package/docs/ssr.md +184 -0
  42. package/docs/time.md +43 -0
  43. package/examples/basic/client/routes/auth.tsx +198 -0
  44. package/examples/basic/client/routes/cookies.tsx +199 -0
  45. package/examples/basic/client/routes/features/index.tsx +34 -10
  46. package/examples/basic/client/routes/hello.tsx +43 -0
  47. package/examples/basic/client/routes/pq.tsx +135 -0
  48. package/examples/basic/server/AuthTestHandler.ts +15 -0
  49. package/examples/basic/server/AuthVerifyHandler.ts +23 -0
  50. package/examples/basic/server/CacheHandler.ts +25 -0
  51. package/examples/basic/server/DecoCache.ts +18 -0
  52. package/examples/basic/server/FastTrapHandler.ts +8 -0
  53. package/examples/basic/server/SpinHandler.ts +18 -0
  54. package/examples/basic/server/SsrGreetingRender.ts +27 -0
  55. package/examples/basic/server/authexample-main.ts +8 -0
  56. package/examples/basic/server/authtest-main.ts +8 -0
  57. package/examples/basic/server/authverify-main.ts +8 -0
  58. package/examples/basic/server/cache-main.ts +8 -0
  59. package/examples/basic/server/core/AppHandler.ts +243 -0
  60. package/examples/basic/server/deco-main.ts +18 -0
  61. package/examples/basic/server/main.ts +2 -0
  62. package/examples/basic/server/routes/Auth.ts +184 -0
  63. package/examples/basic/server/routes/PqDemo.ts +109 -0
  64. package/examples/basic/server/routes/Session.ts +73 -0
  65. package/examples/basic/server/spin-main.ts +13 -0
  66. package/examples/basic/server/ssr/greeting.slots.ts +19 -0
  67. package/examples/basic/server/ssr-main.ts +18 -0
  68. package/examples/basic/server/toil-server-env.d.ts +94 -0
  69. package/examples/basic/server/trap-main.ts +8 -0
  70. package/package.json +5 -3
  71. package/server/globals/auth.ts +281 -0
  72. package/server/runtime/README.md +61 -0
  73. package/server/runtime/env/Server.ts +12 -0
  74. package/server/runtime/exports/index.ts +17 -0
  75. package/server/runtime/exports/render.ts +51 -0
  76. package/server/runtime/http/base64.ts +104 -0
  77. package/server/runtime/http/cookie.ts +416 -0
  78. package/server/runtime/http/cookies.ts +197 -0
  79. package/server/runtime/http/date.ts +72 -0
  80. package/server/runtime/http/percent.ts +76 -0
  81. package/server/runtime/http/securecookies.ts +224 -0
  82. package/server/runtime/index.ts +17 -0
  83. package/server/runtime/request.ts +24 -0
  84. package/server/runtime/response.ts +29 -0
  85. package/server/runtime/ssr/Ssr.ts +43 -0
  86. package/server/runtime/ssr/encode.ts +110 -0
  87. package/server/runtime/ssr/escape.ts +83 -0
  88. package/server/runtime/ssr/slots.ts +144 -0
  89. package/server/runtime/time.ts +29 -0
  90. package/src/cli/create.ts +105 -0
  91. package/src/client/auth.ts +322 -0
  92. package/src/client/index.ts +5 -1
  93. package/src/client/routing/loader.ts +56 -0
  94. package/src/client/routing/mount.tsx +37 -1
  95. package/src/client/ssr/markers.tsx +140 -0
  96. package/src/compiler/docs.ts +88 -1
  97. package/src/compiler/generate.ts +2 -2
  98. package/src/compiler/index.ts +5 -0
  99. package/src/compiler/ssr-codegen.ts +85 -0
  100. package/src/compiler/template-build.ts +275 -0
  101. package/src/compiler/template.ts +265 -0
  102. package/src/devserver/crypto.ts +23 -0
  103. package/src/devserver/host.ts +4 -0
  104. package/src/devserver/module.ts +39 -1
  105. package/test/assembly/cookie.spec.ts +302 -0
  106. package/test/assembly/example.spec.ts +5 -1
  107. package/test/assembly/ssr.spec.ts +94 -0
  108. package/test/devserver.test.ts +42 -0
  109. package/test/ssr-render.test.ts +128 -0
  110. package/test/ssr-template.test.tsx +348 -0
@@ -0,0 +1,94 @@
1
+ // Imports specific SSR modules (not the runtime index, which pulls in the
2
+ // crypto std the as-pect compiler does not ship). These modules are pure
3
+ // (escaping + buffer building + linear-memory encode), so they run under
4
+ // as-pect; the full `render` export is exercised via the example wasm in
5
+ // test/devserver.test.ts.
6
+ import { escapeHtml, escapeJsonForScript } from '../../server/runtime/ssr/escape';
7
+ import { HASH_LEN, HtmlBuilder, SlotKind, SlotValues } from '../../server/runtime/ssr/slots';
8
+ import { encodeValues } from '../../server/runtime/ssr/encode';
9
+
10
+ function bytesToStr(b: Uint8Array): string {
11
+ return String.UTF8.decodeUnsafe(changetype<usize>(b.dataStart), b.length);
12
+ }
13
+
14
+ describe('ssr escape (react-dom byte-identity)', () => {
15
+ it('passes clean text through unchanged', () => {
16
+ expect<string>(escapeHtml('hello world')).toStrictEqual('hello world');
17
+ });
18
+
19
+ it('escapes the five React characters with React entities', () => {
20
+ // React uses &#x27; for the apostrophe and &quot; for the quote.
21
+ expect<string>(escapeHtml('<a href="x">A&B\'s</a>')).toStrictEqual(
22
+ '&lt;a href=&quot;x&quot;&gt;A&amp;B&#x27;s&lt;/a&gt;',
23
+ );
24
+ });
25
+
26
+ it('escapes ampersand first-class (not double-encoding)', () => {
27
+ expect<string>(escapeHtml('a & b')).toStrictEqual('a &amp; b');
28
+ expect<string>(escapeHtml('&amp;')).toStrictEqual('&amp;amp;');
29
+ });
30
+
31
+ it('escapes script-context json delimiters', () => {
32
+ expect<string>(escapeJsonForScript('{"x":"</script>"}')).toStrictEqual(
33
+ '{"x":"\\u003c/script\\u003e"}',
34
+ );
35
+ expect<string>(escapeJsonForScript('{"a":1}')).toStrictEqual('{"a":1}');
36
+ });
37
+ });
38
+
39
+ describe('ssr SlotValues', () => {
40
+ it('escapes text holes and leaves raw holes verbatim', () => {
41
+ const v = new SlotValues(new StaticArray<u8>(HASH_LEN));
42
+ v.setText(0, '<b>');
43
+ v.setRaw(1, '<b>ok</b>');
44
+ expect<i32>(v.slots.length).toBe(2);
45
+ expect<i32>(<i32>v.slots[0].kind).toBe(<i32>SlotKind.TEXT);
46
+ expect<string>(bytesToStr(v.slots[0].bytes)).toStrictEqual('&lt;b&gt;');
47
+ expect<i32>(<i32>v.slots[1].kind).toBe(<i32>SlotKind.RAW);
48
+ expect<string>(bytesToStr(v.slots[1].bytes)).toStrictEqual('<b>ok</b>');
49
+ });
50
+
51
+ it('stamps repeat rows by interleaving raw chunks and escaped values', () => {
52
+ const v = new SlotValues(new StaticArray<u8>(HASH_LEN));
53
+ const rows = new HtmlBuilder();
54
+ const items = ['a&b', 'c'];
55
+ for (let i = 0; i < items.length; i++) {
56
+ rows.raw('<li>').text(items[i]).raw('</li>');
57
+ }
58
+ v.setRepeat(2, rows);
59
+ expect<i32>(<i32>v.slots[0].kind).toBe(<i32>SlotKind.REPEAT);
60
+ expect<string>(bytesToStr(v.slots[0].bytes)).toStrictEqual(
61
+ '<li>a&amp;b</li><li>c</li>',
62
+ );
63
+ });
64
+ });
65
+
66
+ describe('ssr encodeValues wire format', () => {
67
+ it('round-trips status, hash, and one text slot', () => {
68
+ const hash = new StaticArray<u8>(HASH_LEN);
69
+ hash[0] = 0xaa;
70
+ hash[31] = 0xbb;
71
+ const v = new SlotValues(hash);
72
+ v.setStatus(200);
73
+ v.setText(7, 'hi');
74
+
75
+ const buf = new Uint8Array(128);
76
+ const base = changetype<usize>(buf.dataStart);
77
+ const n = encodeValues(v, base);
78
+ // status(2) + hash(32) + n_headers(2) + n_slots(2) + slot[id(2)+kind(1)+len(4)+"hi"(2)]
79
+ expect<i32>(<i32>n).toBe(2 + 32 + 2 + 2 + 2 + 1 + 4 + 2);
80
+
81
+ expect<i32>(<i32>load<u16>(base)).toBe(200); // status
82
+ expect<i32>(<i32>load<u8>(base + 2)).toBe(0xaa); // hash[0]
83
+ expect<i32>(<i32>load<u8>(base + 2 + 31)).toBe(0xbb); // hash[31]
84
+ const afterHash = base + 2 + 32;
85
+ expect<i32>(<i32>load<u16>(afterHash)).toBe(0); // n_headers
86
+ expect<i32>(<i32>load<u16>(afterHash + 2)).toBe(1); // n_slots
87
+ const slot = afterHash + 4;
88
+ expect<i32>(<i32>load<u16>(slot)).toBe(7); // slot_id
89
+ expect<i32>(<i32>load<u8>(slot + 2)).toBe(<i32>SlotKind.TEXT); // kind
90
+ expect<i32>(<i32>load<u32>(slot + 3)).toBe(2); // value_len
91
+ expect<i32>(<i32>load<u8>(slot + 7)).toBe(0x68); // 'h'
92
+ expect<i32>(<i32>load<u8>(slot + 8)).toBe(0x69); // 'i'
93
+ });
94
+ });
@@ -198,4 +198,46 @@ describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('dispatch into the example server
198
198
  const b = get(m, '/json');
199
199
  expect(Buffer.from(a.body).toString()).toBe(Buffer.from(b.body).toString());
200
200
  });
201
+
202
+ // Exercises `SecureCookies` (HMAC-SHA256) end-to-end through the real
203
+ // toilscript-compiled wasm with the Node-backed `env.crypto.*` host imports:
204
+ // `/api/cookies/set` seals a signed `__Host-session`, `/api/cookies/inspect`
205
+ // parses + verifies it. (as-pect cannot compile the crypto std, so this is its coverage.)
206
+ const sessionCookie = (m: WasmServerModule): string => {
207
+ const res = get(m, '/api/cookies/set');
208
+ expect(res.status).toBe(200);
209
+ const pair = res.headers
210
+ .filter(([n]) => n === 'set-cookie')
211
+ .map(([, v]) => v.split(';')[0])
212
+ .find((v) => v.startsWith('__Host-session='));
213
+ expect(pair).toBeDefined();
214
+ return pair!; // "__Host-session=<sealed>"
215
+ };
216
+ const inspect = (m: WasmServerModule, cookie: string): string =>
217
+ Buffer.from(
218
+ m.dispatch({
219
+ method: 'GET',
220
+ path: '/api/cookies/inspect',
221
+ headers: [
222
+ ['host', 'localhost:3000'],
223
+ ['cookie', cookie],
224
+ ],
225
+ body: new Uint8Array(0),
226
+ }).body,
227
+ ).toString();
228
+
229
+ it('round-trips a signed cookie (SecureCookies sign -> unsign)', () => {
230
+ const m = load();
231
+ expect(inspect(m, sessionCookie(m))).toContain('"session":"user-42"');
232
+ });
233
+
234
+ it('rejects a tampered signed cookie', () => {
235
+ const m = load();
236
+ const pair = sessionCookie(m);
237
+ const eq = pair.indexOf('=');
238
+ const name = pair.slice(0, eq);
239
+ const val = pair.slice(eq + 1);
240
+ const tampered = `${name}=${val[0] === 'A' ? 'B' : 'A'}${val.slice(1)}`;
241
+ expect(inspect(m, tampered)).toContain('"session":null');
242
+ });
201
243
  });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * End-to-end guest render: drive the real ToilScript-compiled `render` export
3
+ * of the SSR example wasm, decode its values envelope, and confirm the guest
4
+ * produces exactly the bytes the Rust host's `decode_values`/`assemble` expects
5
+ * (the same wire format proven on the host side in
6
+ * `toil-backend/src/host/template/assemble.rs`). Closing the loop with the
7
+ * golden byte-identity test (`ssr-template.test.tsx`), this proves the full
8
+ * chain: guest values -> splice -> React-identical HTML.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+
16
+ import { WasmServerModule } from '../src/devserver/index.js';
17
+ import { spliceTemplate } from '../src/compiler/template.js';
18
+
19
+ const SSR_WASM = path.resolve(
20
+ path.dirname(fileURLToPath(import.meta.url)),
21
+ '../examples/basic/build/server/ssr.wasm',
22
+ );
23
+
24
+ interface DecodedSlot {
25
+ slotId: number;
26
+ kind: number;
27
+ value: Buffer;
28
+ }
29
+ interface DecodedValues {
30
+ status: number;
31
+ hash: Buffer;
32
+ headers: [string, string][];
33
+ slots: DecodedSlot[];
34
+ }
35
+
36
+ /** Mirror of the host `decode_values`: parse the guest values envelope. */
37
+ function decodeValues(buf: Uint8Array): DecodedValues {
38
+ const b = Buffer.from(buf);
39
+ let o = 0;
40
+ const status = b.readUInt16LE(o);
41
+ o += 2;
42
+ const hash = b.subarray(o, o + 32);
43
+ o += 32;
44
+ const nHeaders = b.readUInt16LE(o);
45
+ o += 2;
46
+ const headers: [string, string][] = [];
47
+ for (let i = 0; i < nHeaders; i++) {
48
+ const nameLen = b.readUInt16LE(o);
49
+ o += 2;
50
+ const valLen = b.readUInt16LE(o);
51
+ o += 2;
52
+ const name = b.toString('utf8', o, o + nameLen);
53
+ o += nameLen;
54
+ const val = b.toString('utf8', o, o + valLen);
55
+ o += valLen;
56
+ headers.push([name, val]);
57
+ }
58
+ const nSlots = b.readUInt16LE(o);
59
+ o += 2;
60
+ const slots: DecodedSlot[] = [];
61
+ for (let i = 0; i < nSlots; i++) {
62
+ const slotId = b.readUInt16LE(o);
63
+ o += 2;
64
+ const kind = b.readUInt8(o);
65
+ o += 1;
66
+ const valueLen = b.readUInt32LE(o);
67
+ o += 4;
68
+ slots.push({ slotId, kind, value: b.subarray(o, o + valueLen) });
69
+ o += valueLen;
70
+ }
71
+ expect(o).toBe(b.length); // no trailing garbage
72
+ return { status, hash, headers, slots };
73
+ }
74
+
75
+ describe.skipIf(!fs.existsSync(SSR_WASM))('edge SSR guest render (real wasm)', () => {
76
+ const load = (): WasmServerModule => {
77
+ const m = new WasmServerModule(SSR_WASM);
78
+ m.refresh();
79
+ return m;
80
+ };
81
+ const render = (m: WasmServerModule, p: string): Uint8Array =>
82
+ m.dispatchRender({
83
+ method: 'GET',
84
+ path: p,
85
+ headers: [['host', 'localhost']],
86
+ body: new Uint8Array(0),
87
+ });
88
+
89
+ it('produces a values envelope with the compiled-in hash and escaped holes', () => {
90
+ const d = decodeValues(render(load(), '/hello'));
91
+ expect(d.status).toBe(200);
92
+ // The HASH baked into greeting.slots.ts: bytes 0x00..0x1f.
93
+ expect([...d.hash]).toEqual(Array.from({ length: 32 }, (_, i) => i));
94
+
95
+ expect(d.slots.map((s) => `${s.slotId}:${s.kind}`)).toEqual([
96
+ '0:0', // greeting, text
97
+ '1:3', // count, repeat
98
+ ]);
99
+ // Text hole: React-escaped (& -> &amp;, <> -> entities, mirroring escape.ts).
100
+ expect(d.slots[0].value.toString('utf8')).toBe('world &amp; &lt;friends&gt;');
101
+ // Repeat hole: three stamped rows, each escaped.
102
+ expect(d.slots[1].value.toString('utf8')).toBe(
103
+ '<li>a &amp; b</li><li>&lt;c&gt;</li><li>d</li>',
104
+ );
105
+ });
106
+
107
+ it('splices into a template exactly (full guest -> host -> HTML chain)', () => {
108
+ const d = decodeValues(render(load(), '/hello'));
109
+ // A template with a greeting hole and a repeat region, holes removed.
110
+ const tmpl = Buffer.from('<h1>Hello </h1><ul></ul>', 'utf8');
111
+ const greetingOffset = '<h1>Hello '.length;
112
+ const repeatOffset = '<h1>Hello </h1><ul>'.length;
113
+ const out = spliceTemplate(tmpl, [
114
+ { offset: greetingOffset, value: Buffer.from(d.slots[0].value) },
115
+ { offset: repeatOffset, value: Buffer.from(d.slots[1].value) },
116
+ ]);
117
+ expect(out.toString('utf8')).toBe(
118
+ '<h1>Hello world &amp; &lt;friends&gt;</h1><ul><li>a &amp; b</li><li>&lt;c&gt;</li><li>d</li></ul>',
119
+ );
120
+ });
121
+
122
+ it('fails safe (status 500, zero hash, no slots) for an unmatched path', () => {
123
+ const d = decodeValues(render(load(), '/not-an-ssr-route'));
124
+ expect(d.status).toBe(500);
125
+ expect([...d.hash]).toEqual(Array(32).fill(0));
126
+ expect(d.slots).toHaveLength(0);
127
+ });
128
+ });
@@ -0,0 +1,348 @@
1
+ /**
2
+ * The load-bearing test for edge SSR: prove that the build's
3
+ * template-with-holes + a guest-style stamp reproduces, BYTE FOR BYTE, what
4
+ * React renders for the same data. If this holds, the browser's `hydrateRoot`
5
+ * sees identical markup and hydration is clean.
6
+ *
7
+ * Strategy:
8
+ * 1. Render the component with markers in SENTINEL mode (build) -> strip ->
9
+ * `.tmpl` + slot records.
10
+ * 2. Render the SAME component with REAL data normally -> `expected`.
11
+ * 3. Simulate the guest: stamp each hole's value (React-escaped text, raw
12
+ * verbatim, repeat = stamp the captured row template per real item) and
13
+ * splice into the `.tmpl`.
14
+ * 4. assert stamped === expected.
15
+ */
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ import { renderToStaticMarkup } from 'react-dom/server';
21
+ import { describe, expect, it } from 'vitest';
22
+
23
+ import { Hole, Island, RawHtml, Repeat, __setSsrBuild } from '../src/client/ssr/markers';
24
+ import { LoaderDataContext, useLoaderData } from '../src/client/routing/loader';
25
+ import {
26
+ extractRouteTemplate,
27
+ injectIntoShell,
28
+ routeTemplateName,
29
+ writeTemplateArtifacts,
30
+ } from '../src/compiler/template-build';
31
+ import {
32
+ assignSlotIds,
33
+ coherenceHash,
34
+ encodeSlots,
35
+ extractFromHtml,
36
+ kindByte,
37
+ reactEscapeHtml,
38
+ spliceTemplate,
39
+ type SlotRecord,
40
+ } from '../src/compiler/template';
41
+ import { generateSlotsModule } from '../src/compiler/ssr-codegen';
42
+
43
+ interface Post {
44
+ title: string;
45
+ }
46
+ interface ProfileData {
47
+ username: string;
48
+ bioHtml: string;
49
+ posts: Post[];
50
+ }
51
+
52
+ function Profile({ d }: { d: ProfileData }): React.ReactElement {
53
+ return (
54
+ <main>
55
+ <h1>
56
+ @<Hole id="username">{d.username}</Hole>
57
+ </h1>
58
+ <RawHtml id="bio" html={d.bioHtml} />
59
+ <ul>
60
+ <Repeat id="posts" each={d.posts}>
61
+ {(p: Post) => (
62
+ <li>
63
+ <Hole id="title">{p.title}</Hole>
64
+ </li>
65
+ )}
66
+ </Repeat>
67
+ </ul>
68
+ </main>
69
+ );
70
+ }
71
+
72
+ /** Render under the build sentinel pass (always restores the flag). */
73
+ function renderBuild(el: React.ReactElement): string {
74
+ __setSsrBuild(true);
75
+ try {
76
+ return renderToStaticMarkup(el);
77
+ } finally {
78
+ __setSsrBuild(false);
79
+ }
80
+ }
81
+
82
+ /** Simulate the guest: produce the bytes for one top-level slot from real data. */
83
+ function stampSlot(slot: SlotRecord, d: ProfileData): Buffer {
84
+ if (slot.kind === 'text' && slot.id === 'username') {
85
+ return Buffer.from(reactEscapeHtml(d.username), 'utf8');
86
+ }
87
+ if (slot.kind === 'raw' && slot.id === 'bio') {
88
+ return Buffer.from(d.bioHtml, 'utf8');
89
+ }
90
+ if (slot.kind === 'repeat' && slot.id === 'posts') {
91
+ const rowTmpl = slot.rowTemplate!;
92
+ const rows: Buffer[] = d.posts.map((p) =>
93
+ spliceTemplate(
94
+ rowTmpl,
95
+ slot.rowSlots!.map((rs) => ({
96
+ offset: rs.offset,
97
+ value: Buffer.from(reactEscapeHtml(p.title), 'utf8'),
98
+ })),
99
+ ),
100
+ );
101
+ return Buffer.concat(rows);
102
+ }
103
+ throw new Error(`unexpected slot ${slot.id}/${slot.kind}`);
104
+ }
105
+
106
+ function assemble(tmpl: Buffer, slots: SlotRecord[], d: ProfileData): Buffer {
107
+ return spliceTemplate(
108
+ tmpl,
109
+ slots.map((s) => ({ offset: s.offset, value: stampSlot(s, d) })),
110
+ );
111
+ }
112
+
113
+ describe('ssr template extraction', () => {
114
+ it('strips sentinels and records hole offsets', () => {
115
+ const sample: ProfileData = {
116
+ username: 'ada',
117
+ bioHtml: '<em>hi</em>',
118
+ posts: [{ title: 'first' }],
119
+ };
120
+ const { tmpl, slots } = extractFromHtml(renderBuild(<Profile d={sample} />));
121
+
122
+ // Static scaffold = React's own output, holes removed. The <div> from
123
+ // RawHtml's wrapper is present and empty; the <ul> is empty.
124
+ expect(tmpl.toString('utf8')).toBe('<main><h1>@</h1><div></div><ul></ul></main>');
125
+
126
+ expect(slots.map((s) => `${s.id}:${s.kind}`)).toEqual([
127
+ 'username:text',
128
+ 'bio:raw',
129
+ 'posts:repeat',
130
+ ]);
131
+ // Offsets are ascending and land at the right spots.
132
+ const t = tmpl.toString('utf8');
133
+ expect(t.slice(0, slots[0].offset)).toBe('<main><h1>@');
134
+ expect(slots[1].offset).toBe(t.indexOf('<div>') + '<div>'.length);
135
+ expect(slots[2].offset).toBe(t.indexOf('<ul>') + '<ul>'.length);
136
+
137
+ // Repeat captured the single-row sub-template + its nested hole.
138
+ const posts = slots[2];
139
+ expect(posts.rowTemplate!.toString('utf8')).toBe('<li></li>');
140
+ expect(posts.rowSlots!.map((r) => `${r.id}:${r.kind}`)).toEqual(['title:text']);
141
+ expect(posts.rowSlots![0].offset).toBe('<li>'.length);
142
+ });
143
+
144
+ it('collapses Island to nothing server-side', () => {
145
+ const html = renderBuild(
146
+ <div>
147
+ <Island>
148
+ <span>client only</span>
149
+ </Island>
150
+ </div>,
151
+ );
152
+ expect(extractFromHtml(html).tmpl.toString('utf8')).toBe('<div></div>');
153
+ });
154
+ });
155
+
156
+ describe('ssr GOLDEN byte-identity (template+stamp === React render)', () => {
157
+ const sample: ProfileData = {
158
+ username: 'ada',
159
+ bioHtml: '<em>sample</em>',
160
+ posts: [{ title: 'sample' }],
161
+ };
162
+
163
+ function check(real: ProfileData): void {
164
+ const { tmpl, slots } = extractFromHtml(renderBuild(<Profile d={sample} />));
165
+ const stamped = assemble(tmpl, slots, real);
166
+ const expected = renderToStaticMarkup(<Profile d={real} />);
167
+ expect(stamped.toString('utf8')).toBe(expected);
168
+ }
169
+
170
+ it('matches for plain data', () => {
171
+ check({
172
+ username: 'grace',
173
+ bioHtml: '<strong>Rear Admiral</strong>',
174
+ posts: [{ title: 'COBOL' }, { title: 'the bug' }, { title: 'compilers' }],
175
+ });
176
+ });
177
+
178
+ it('matches when values need React escaping (the &#x27; / &quot; cases)', () => {
179
+ check({
180
+ username: `A<b>&"'x`,
181
+ bioHtml: '<em>raw & <kept></em>', // raw: NOT escaped, verbatim
182
+ posts: [{ title: 'a & b' }, { title: '<script>' }, { title: `it's "ok"` }],
183
+ });
184
+ });
185
+
186
+ it('matches for an empty list (zero rows)', () => {
187
+ check({ username: 'zero', bioHtml: '<i/>', posts: [] });
188
+ });
189
+
190
+ it('matches for a single row', () => {
191
+ check({ username: 'one', bioHtml: 'x', posts: [{ title: 'only' }] });
192
+ });
193
+ });
194
+
195
+ describe('ssr .slots binary manifest', () => {
196
+ it('encodes the documented layout and round-trips offsets/kinds', () => {
197
+ const sample: ProfileData = {
198
+ username: 'ada',
199
+ bioHtml: '<em>x</em>',
200
+ posts: [{ title: 't' }],
201
+ };
202
+ const { tmpl, slots } = extractFromHtml(renderBuild(<Profile d={sample} />));
203
+ const ids = assignSlotIds(slots);
204
+ const hash = coherenceHash(tmpl, slots);
205
+ const buf = encodeSlots(tmpl.length, hash, slots, ids);
206
+
207
+ // Header: magic "TSLT", version 1, flags 0, tmpl_len, hash, n_slots.
208
+ expect(buf.subarray(0, 4).toString('ascii')).toBe('TSLT');
209
+ expect(buf.readUInt16LE(4)).toBe(1);
210
+ expect(buf.readUInt16LE(6)).toBe(0);
211
+ expect(buf.readUInt32LE(8)).toBe(tmpl.length);
212
+ expect(buf.subarray(12, 44).equals(hash)).toBe(true);
213
+ expect(buf.readUInt16LE(44)).toBe(slots.length);
214
+
215
+ // Slot entries: offset u32, slot_id u16, kind u8, reserved u8.
216
+ let o = 46;
217
+ slots.forEach((s, i) => {
218
+ expect(buf.readUInt32LE(o)).toBe(s.offset);
219
+ expect(buf.readUInt16LE(o + 4)).toBe(ids.get(s.id));
220
+ expect(buf.readUInt8(o + 6)).toBe(kindByte(s.kind));
221
+ expect(buf.readUInt8(o + 7)).toBe(0);
222
+ expect(ids.get(s.id)).toBe(i); // document-order numbering
223
+ o += 8;
224
+ });
225
+ expect(o).toBe(buf.length);
226
+ });
227
+
228
+ it('coherence hash changes when the template changes', () => {
229
+ const a = extractFromHtml(
230
+ renderBuild(<Profile d={{ username: 'a', bioHtml: 'x', posts: [{ title: 't' }] }} />),
231
+ );
232
+ const h1 = coherenceHash(a.tmpl, a.slots);
233
+ const h2 = coherenceHash(Buffer.concat([a.tmpl, Buffer.from('!')]), a.slots);
234
+ expect(h1.equals(h2)).toBe(false);
235
+ });
236
+ });
237
+
238
+ describe('ssr guest codegen (Slot enum + HASH)', () => {
239
+ const sample: ProfileData = {
240
+ username: 'ada',
241
+ bioHtml: '<em>x</em>',
242
+ posts: [{ title: 't' }],
243
+ };
244
+
245
+ it('emits a Slot enum matching the .slots numbering and the 32-byte HASH', () => {
246
+ const { tmpl, slots } = extractFromHtml(renderBuild(<Profile d={sample} />));
247
+ const ids = assignSlotIds(slots);
248
+ const hash = coherenceHash(tmpl, slots);
249
+ const mod = generateSlotsModule('u_name', slots, hash);
250
+
251
+ // Enum members use the SAME ids the host .slots carries.
252
+ expect(mod).toContain('export enum Slot {');
253
+ expect(mod).toContain(`username = ${ids.get('username')},`);
254
+ expect(mod).toContain(`bio = ${ids.get('bio')},`);
255
+ expect(mod).toContain(`posts = ${ids.get('posts')},`);
256
+ // HASH literal has exactly 32 bytes.
257
+ const arr = mod.match(/HASH: StaticArray<u8> = \[([^\]]*)\]/)![1];
258
+ expect(arr.split(',').filter((s) => s.trim().length > 0)).toHaveLength(32);
259
+ expect(mod).toContain(`0x${hash[0].toString(16).padStart(2, '0')}`);
260
+ });
261
+
262
+ it('rejects a hole id that is not a valid identifier', () => {
263
+ const bad: SlotRecord[] = [{ id: 'has-dash', kind: 'text', offset: 0 }];
264
+ expect(() => generateSlotsModule('r', bad, Buffer.alloc(32))).toThrow(/not a valid identifier/);
265
+ });
266
+ });
267
+
268
+ describe('ssr build orchestration', () => {
269
+ const SHELL =
270
+ '<!doctype html><html><head><title>t</title></head><body><div id="root"></div>' +
271
+ '<script type="module" src="/assets/app-abc123.js"></script></body></html>';
272
+
273
+ // A page that reads its data from the loader context (exercises the provider
274
+ // path), wrapped in a simple SSR-safe layout.
275
+ function ProfilePage(): React.ReactElement {
276
+ const d = useLoaderData<ProfileData>();
277
+ return <Profile d={d} />;
278
+ }
279
+ function Layout({ children }: { children?: React.ReactNode }): React.ReactElement {
280
+ return <div className="app">{children}</div>;
281
+ }
282
+
283
+ const sample: ProfileData = {
284
+ username: 'ada',
285
+ bioHtml: '<em>x</em>',
286
+ posts: [{ title: 't' }],
287
+ };
288
+
289
+ it('sanitizes route patterns into file-safe names', () => {
290
+ expect(routeTemplateName('/u/:name')).toBe('u_name');
291
+ expect(routeTemplateName('/')).toBe('index');
292
+ expect(routeTemplateName('/blog/[id]')).toBe('blog_id');
293
+ });
294
+
295
+ it('injects rendered route HTML into the shell #root with the SSR marker', () => {
296
+ const out = injectIntoShell(SHELL, '<main>hi</main>');
297
+ expect(out).toContain('<div id="root"><main>hi</main></div>');
298
+ expect(out).toContain('<template id="__toil_ssr"></template>');
299
+ // the hashed script tag is preserved (so hydration boots)
300
+ expect(out).toContain('/assets/app-abc123.js');
301
+ });
302
+
303
+ it('renders a route (page + layout + loader data) to template artifacts and writes them', () => {
304
+ const art = extractRouteTemplate({
305
+ name: 'profile',
306
+ Page: ProfilePage,
307
+ layouts: [Layout],
308
+ loaderData: sample,
309
+ loaderContext: LoaderDataContext,
310
+ setSsrBuild: __setSsrBuild,
311
+ shell: SHELL,
312
+ });
313
+
314
+ const tmpl = art.tmpl.toString('utf8');
315
+ // Full document with the layout + page scaffold spliced into #root, holes removed.
316
+ expect(tmpl).toContain(
317
+ '<div id="root"><div class="app"><main><h1>@</h1><div></div><ul></ul></main></div></div>',
318
+ );
319
+ expect(tmpl).toContain('<template id="__toil_ssr"></template>');
320
+ expect(tmpl).toContain('/assets/app-abc123.js'); // bootstrap script preserved
321
+ expect(art.slotCount).toBe(3); // username, bio, posts
322
+ expect(art.hash).toHaveLength(32);
323
+
324
+ // The generated AS Slot module names every hole.
325
+ expect(art.slotsModule).toContain('export enum Slot {');
326
+ for (const id of ['username', 'bio', 'posts']) expect(art.slotsModule).toContain(`${id} =`);
327
+
328
+ // Write to disk and read back; the .slots header is well-formed and the
329
+ // tmpl_len in it matches the .tmpl byte length (the host's first check).
330
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-ssr-'));
331
+ try {
332
+ writeTemplateArtifacts(dir, art);
333
+ const tmplFile = fs.readFileSync(path.join(dir, 'profile.tmpl'));
334
+ const slotsFile = fs.readFileSync(path.join(dir, 'profile.slots'));
335
+ const modFile = fs.readFileSync(path.join(dir, 'profile.slots.ts'), 'utf8');
336
+
337
+ expect(tmplFile.equals(art.tmpl)).toBe(true);
338
+ expect(slotsFile.subarray(0, 4).toString('ascii')).toBe('TSLT');
339
+ expect(slotsFile.readUInt16LE(4)).toBe(1); // version
340
+ expect(slotsFile.readUInt32LE(8)).toBe(tmplFile.length); // tmpl_len matches .tmpl
341
+ expect(slotsFile.subarray(12, 44).equals(art.hash)).toBe(true);
342
+ expect(slotsFile.readUInt16LE(44)).toBe(3); // n_slots
343
+ expect(modFile).toContain('export const HASH: StaticArray<u8>');
344
+ } finally {
345
+ fs.rmSync(dir, { recursive: true, force: true });
346
+ }
347
+ });
348
+ });