toiljs 0.0.33 → 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 (130) hide show
  1. package/CHANGELOG.md +19 -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 +124 -7
  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/cache.d.ts +8 -0
  29. package/build/devserver/cache.js +0 -0
  30. package/build/devserver/crypto.js +15 -0
  31. package/build/devserver/host.js +1 -0
  32. package/build/devserver/index.js +10 -1
  33. package/build/devserver/module.d.ts +1 -0
  34. package/build/devserver/module.js +23 -1
  35. package/docs/README.md +56 -0
  36. package/docs/auth.md +261 -0
  37. package/docs/caching.md +115 -0
  38. package/docs/cookies.md +457 -0
  39. package/docs/crypto.md +130 -0
  40. package/docs/data.md +131 -0
  41. package/docs/getting-started.md +128 -0
  42. package/docs/routing.md +259 -0
  43. package/docs/rpc.md +149 -0
  44. package/docs/ssr.md +184 -0
  45. package/docs/time.md +43 -0
  46. package/examples/basic/client/routes/auth.tsx +198 -0
  47. package/examples/basic/client/routes/cookies.tsx +199 -0
  48. package/examples/basic/client/routes/features/index.tsx +34 -10
  49. package/examples/basic/client/routes/hello.tsx +43 -0
  50. package/examples/basic/client/routes/pq.tsx +135 -0
  51. package/examples/basic/server/AuthTestHandler.ts +15 -0
  52. package/examples/basic/server/AuthVerifyHandler.ts +23 -0
  53. package/examples/basic/server/CacheHandler.ts +25 -0
  54. package/examples/basic/server/DecoCache.ts +18 -0
  55. package/examples/basic/server/FastTrapHandler.ts +8 -0
  56. package/examples/basic/server/README.md +19 -0
  57. package/examples/basic/server/SpinHandler.ts +18 -0
  58. package/examples/basic/server/SsrGreetingRender.ts +27 -0
  59. package/examples/basic/server/authexample-main.ts +8 -0
  60. package/examples/basic/server/authtest-main.ts +8 -0
  61. package/examples/basic/server/authverify-main.ts +8 -0
  62. package/examples/basic/server/cache-main.ts +8 -0
  63. package/examples/basic/server/core/AppHandler.ts +290 -0
  64. package/examples/basic/server/core/store.ts +31 -0
  65. package/examples/basic/server/deco-main.ts +18 -0
  66. package/examples/basic/server/main.ts +13 -2
  67. package/examples/basic/server/models/NewPlayer.ts +5 -0
  68. package/examples/basic/server/models/Player.ts +8 -0
  69. package/examples/basic/server/models/ScoreDelta.ts +5 -0
  70. package/examples/basic/server/models/Standings.ts +7 -0
  71. package/examples/basic/server/routes/Auth.ts +184 -0
  72. package/examples/basic/server/routes/Leaderboard.ts +20 -0
  73. package/examples/basic/server/routes/Players.ts +53 -0
  74. package/examples/basic/server/routes/PqDemo.ts +109 -0
  75. package/examples/basic/server/routes/Session.ts +73 -0
  76. package/examples/basic/server/scheduled/README.md +7 -0
  77. package/examples/basic/server/services/Stats.ts +11 -0
  78. package/examples/basic/server/services/remotes.ts +7 -0
  79. package/examples/basic/server/spin-main.ts +13 -0
  80. package/examples/basic/server/ssr/greeting.slots.ts +19 -0
  81. package/examples/basic/server/ssr-main.ts +18 -0
  82. package/examples/basic/server/toil-server-env.d.ts +94 -0
  83. package/examples/basic/server/trap-main.ts +8 -0
  84. package/package.json +5 -3
  85. package/server/globals/auth.ts +281 -0
  86. package/server/runtime/README.md +61 -0
  87. package/server/runtime/env/Server.ts +12 -0
  88. package/server/runtime/exports/index.ts +17 -0
  89. package/server/runtime/exports/render.ts +51 -0
  90. package/server/runtime/http/base64.ts +104 -0
  91. package/server/runtime/http/cookie.ts +416 -0
  92. package/server/runtime/http/cookies.ts +197 -0
  93. package/server/runtime/http/date.ts +72 -0
  94. package/server/runtime/http/percent.ts +76 -0
  95. package/server/runtime/http/securecookies.ts +224 -0
  96. package/server/runtime/index.ts +17 -0
  97. package/server/runtime/request.ts +24 -0
  98. package/server/runtime/response.ts +85 -0
  99. package/server/runtime/ssr/Ssr.ts +43 -0
  100. package/server/runtime/ssr/encode.ts +110 -0
  101. package/server/runtime/ssr/escape.ts +83 -0
  102. package/server/runtime/ssr/slots.ts +144 -0
  103. package/server/runtime/time.ts +29 -0
  104. package/src/cli/create.ts +159 -14
  105. package/src/client/auth.ts +322 -0
  106. package/src/client/index.ts +5 -1
  107. package/src/client/routing/loader.ts +56 -0
  108. package/src/client/routing/mount.tsx +37 -1
  109. package/src/client/ssr/markers.tsx +140 -0
  110. package/src/compiler/docs.ts +88 -1
  111. package/src/compiler/generate.ts +2 -2
  112. package/src/compiler/index.ts +5 -0
  113. package/src/compiler/ssr-codegen.ts +85 -0
  114. package/src/compiler/template-build.ts +275 -0
  115. package/src/compiler/template.ts +265 -0
  116. package/src/devserver/cache.ts +0 -0
  117. package/src/devserver/crypto.ts +23 -0
  118. package/src/devserver/host.ts +4 -0
  119. package/src/devserver/index.ts +21 -1
  120. package/src/devserver/module.ts +39 -1
  121. package/test/assembly/cookie.spec.ts +302 -0
  122. package/test/assembly/example.spec.ts +5 -1
  123. package/test/assembly/ssr.spec.ts +94 -0
  124. package/test/devserver.test.ts +48 -4
  125. package/test/fixtures/bignum-wire/spec.ts +27 -0
  126. package/test/rpc-bignum-wire.test.ts +164 -0
  127. package/test/ssr-render.test.ts +128 -0
  128. package/test/ssr-template.test.tsx +348 -0
  129. package/examples/basic/server/HelloHandler.ts +0 -42
  130. package/examples/basic/server/api.ts +0 -137
@@ -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
+ });
@@ -1,42 +0,0 @@
1
- import { Method, Request, Response, Rest, ToilHandler } from 'toiljs/server/runtime';
2
-
3
- export class HelloHandler extends ToilHandler {
4
- public handle(req: Request): Response {
5
- // Try the @rest controllers first (see api.ts). Rest.dispatch returns the
6
- // first matching route's Response, or null if nothing matched - then we fall
7
- // through to our own logic. REST composes; it never takes over handle().
8
- const hit = Rest.dispatch(req);
9
- if (hit != null) {
10
- return hit;
11
- }
12
-
13
- if (req.method != Method.GET && req.method != Method.HEAD) {
14
- return Response.empty(405).setHeader('allow', 'GET, HEAD');
15
- }
16
-
17
- if (req.path == '/json') {
18
- return Response.json('{"hello":"toiljs"}\n');
19
- }
20
-
21
- if (req.path == '/echo') {
22
- return Response.text('you GET ' + req.path + '\n');
23
- }
24
-
25
- // Web Crypto demo. `crypto` is a global (no import), synchronous: the
26
- // same SubtleCrypto-style API the browser has, running in the server
27
- // wasm via metered host functions.
28
- if (req.path == '/api/hash') {
29
- const digest = crypto.sha256Text(req.path);
30
- return Response.json('{"sha256":"' + crypto.toHex(digest) + '"}\n');
31
- }
32
-
33
- if (req.path == '/api/uuid') {
34
- return Response.text(crypto.randomUUID() + '\n');
35
- }
36
-
37
- // Unhandled (not a plain notFound): tells the host this server has no
38
- // answer for the path, so it may serve it itself. Under `toiljs dev`
39
- // that falls through to Vite (client routes, assets).
40
- return Response.unhandled();
41
- }
42
- }
@@ -1,137 +0,0 @@
1
- // A small REST demo: a players list + a leaderboard.
2
- //
3
- // IMPORTANT - the server runs with a FRESH WebAssembly instance per request, so linear memory
4
- // (and any module-level state, like the `store` below) is reset on every request. It does NOT
5
- // persist across requests. We seed a few players at module init so the read routes always have
6
- // data; the write routes (create / addScore) take effect only for the current request's response.
7
- // For real persistence, call out to a database or KV store from your handler.
8
- //
9
- // `@data` types are the wire model (shared by HTTP and RPC). `@rest` controllers expose HTTP
10
- // routes; `@service`/`@remote` expose typed RPC. Building the server (toilscript --rpcModule)
11
- // regenerates the typed client into shared/server.ts:
12
- // @rest -> Server.REST.<controller>.<route>(args) (a working fetch client)
13
- // @service -> Server.<service>.<method>() (RPC, transport TODO)
14
-
15
- import { Response, RouteContext } from 'toiljs/server/runtime';
16
-
17
- /** A leaderboard player. */
18
- @data
19
- class Player {
20
- id: u64 = 0;
21
- name: string = '';
22
- score: i64 = 0;
23
- }
24
-
25
- /** Request body for `POST /players` - the fields a client supplies to create a player. */
26
- @data
27
- class NewPlayer {
28
- name: string = '';
29
- }
30
-
31
- /** Request body for `POST /players/:id/score` - points to add to a player's score. */
32
- @data
33
- class ScoreDelta {
34
- points: i64 = 0;
35
- }
36
-
37
- /** A leaderboard page. A `@data` wrapper so the `Player[]` round-trips through the codec. */
38
- @data
39
- class Standings {
40
- players: Player[] = [];
41
- }
42
-
43
- // Re-seeded on EVERY request - module memory does not persist across requests (see the note at
44
- // the top of the file). Swap this for a database/KV to keep data between requests.
45
- const store = new Map<u64, Player>();
46
- let nextId: u64 = 1;
47
-
48
- function seed(name: string, score: i64): void {
49
- const p = new Player();
50
- p.id = nextId++;
51
- p.name = name;
52
- p.score = score;
53
- store.set(p.id, p);
54
- }
55
- seed('Ada', 120);
56
- seed('Linus', 95);
57
- seed('Grace', 140);
58
-
59
- /**
60
- * Players, mounted at `/players`. On the client:
61
- * await Server.REST.players.get({ params: { id } })
62
- * await Server.REST.players.create({ body: new NewPlayer('Bob') })
63
- * await Server.REST.players.addScore({ params: { id }, body: new ScoreDelta(10n) })
64
- */
65
- @rest('players')
66
- class Players {
67
- /** `GET /players/:id` - returns a `Response` for full control: a real 404 for a missing id,
68
- * a custom header, and the `@data` body serialized with `toJSON()`. (The toilscript editor
69
- * plugin types the compiler-injected `toJSON()`, so this is clean; return the `@data` type
70
- * directly, like the other routes, when you do not need that control.) */
71
- @get('/:id')
72
- public get(ctx: RouteContext): Response {
73
- const id = u64.parse(ctx.param('id'));
74
- if (!store.has(id)) return Response.notFound();
75
- const p = store.get(id);
76
-
77
- return Response.json(p.toJSON().toString()).setHeader('cache-control', 'no-store');
78
- }
79
-
80
- /** `POST /players` - build a player from the request body and return it with a fresh id.
81
- * Note: it is NOT saved (memory resets next request); persist to a real store to keep it. */
82
- @post('/')
83
- public create(input: NewPlayer): Player {
84
- const p = new Player();
85
- p.id = nextId++;
86
- p.name = input.name;
87
- p.score = 0;
88
- store.set(p.id, p);
89
-
90
- return p;
91
- }
92
-
93
- /** `POST /players/:id/score` - add `points` (from the body) to the seeded player named by
94
- * `:id` and return it. The change applies to this response only (memory resets next request). */
95
- @post('/:id/score')
96
- public addScore(input: ScoreDelta, ctx: RouteContext): Player {
97
- const id = u64.parse(ctx.param('id'));
98
- if (!store.has(id)) return new Player();
99
- const p = store.get(id);
100
- p.score += input.points;
101
-
102
- return p;
103
- }
104
- }
105
-
106
- /**
107
- * The leaderboard, mounted at `/leaderboard`. On the client:
108
- * const board = await Server.REST.leaderboard.top(); // typed Standings { players: Player[] }
109
- */
110
- @rest('leaderboard')
111
- class Leaderboard {
112
- /** `GET /leaderboard` - the seeded players, highest score first. */
113
- @get('/')
114
- public top(): Standings {
115
- const board = new Standings();
116
- const all = store.values();
117
- for (let i = 0; i < all.length; i++) board.players.push(all[i]);
118
- board.players.sort((a: Player, b: Player): i32 => (a.score < b.score ? 1 : a.score > b.score ? -1 : 0));
119
- return board;
120
- }
121
- }
122
-
123
- /** Typed RPC service (transport still a TODO): reached as `Server.stats.playerCount()` on the client. */
124
- @service
125
- class Stats {
126
- /** Number of seeded players (the RPC transport is a TODO, so this throws on the client for now). */
127
- @remote
128
- public playerCount(): i32 {
129
- return store.size;
130
- }
131
- }
132
-
133
- /** A free `@remote` function: `Server.ping(n)` on the client. */
134
- @remote
135
- function ping(n: i32): i32 {
136
- return n + 1;
137
- }