toiljs 0.0.66 → 0.0.68

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 (111) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +63 -61
  3. package/build/backend/.tsbuildinfo +1 -1
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +13 -1
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/index.d.ts +2 -0
  8. package/build/client/index.js +1 -0
  9. package/build/client/rpc.js +21 -1
  10. package/build/client/stream/client.d.ts +11 -0
  11. package/build/client/stream/client.js +59 -0
  12. package/build/compiler/.tsbuildinfo +1 -1
  13. package/build/compiler/config.d.ts +2 -0
  14. package/build/compiler/config.js +9 -7
  15. package/build/compiler/index.d.ts +1 -0
  16. package/build/compiler/index.js +22 -6
  17. package/build/compiler/toil-docs.generated.js +3 -3
  18. package/build/devserver/.tsbuildinfo +1 -1
  19. package/build/devserver/daemon/index.js +4 -3
  20. package/build/devserver/daemon/runtime.d.ts +13 -0
  21. package/build/devserver/daemon/runtime.js +29 -0
  22. package/build/devserver/db/catalog.js +8 -12
  23. package/build/devserver/db/database.d.ts +1 -0
  24. package/build/devserver/db/database.js +10 -0
  25. package/build/devserver/db/derives.d.ts +7 -0
  26. package/build/devserver/db/derives.js +94 -0
  27. package/build/devserver/db/index.d.ts +1 -0
  28. package/build/devserver/db/index.js +1 -0
  29. package/build/devserver/db/types.d.ts +1 -0
  30. package/build/devserver/db/types.js +1 -0
  31. package/build/devserver/http/proxy.d.ts +5 -1
  32. package/build/devserver/http/proxy.js +39 -36
  33. package/build/devserver/http/runtime.d.ts +62 -0
  34. package/build/devserver/http/runtime.js +194 -0
  35. package/build/devserver/index.d.ts +2 -0
  36. package/build/devserver/index.js +1 -0
  37. package/build/devserver/production-ipc.d.ts +50 -0
  38. package/build/devserver/production-ipc.js +21 -0
  39. package/build/devserver/production-worker.d.ts +1 -0
  40. package/build/devserver/production-worker.js +73 -0
  41. package/build/devserver/production.d.ts +35 -0
  42. package/build/devserver/production.js +502 -0
  43. package/build/devserver/runtime/module.d.ts +5 -0
  44. package/build/devserver/runtime/module.js +47 -1
  45. package/build/devserver/server.d.ts +1 -0
  46. package/build/devserver/server.js +32 -145
  47. package/build/devserver/ssr.d.ts +2 -0
  48. package/build/devserver/ssr.js +19 -2
  49. package/build/devserver/stream/catalog.d.ts +20 -0
  50. package/build/devserver/stream/catalog.js +54 -0
  51. package/build/devserver/stream/host.d.ts +9 -0
  52. package/build/devserver/stream/host.js +15 -0
  53. package/build/devserver/stream/index.d.ts +37 -0
  54. package/build/devserver/stream/index.js +220 -0
  55. package/build/devserver/stream/manager.d.ts +34 -0
  56. package/build/devserver/stream/manager.js +103 -0
  57. package/build/devserver/stream/router.d.ts +25 -0
  58. package/build/devserver/stream/router.js +64 -0
  59. package/build/devserver/stream/wire.d.ts +5 -0
  60. package/build/devserver/stream/wire.js +33 -0
  61. package/build/devserver/stream/ws.d.ts +18 -0
  62. package/build/devserver/stream/ws.js +46 -0
  63. package/build/devserver/wasm/surface.d.ts +1 -1
  64. package/build/devserver/wasm/surface.js +1 -1
  65. package/docs/cli.md +3 -1
  66. package/docs/getting-started.md +7 -7
  67. package/docs/tiers.md +15 -9
  68. package/examples/basic/server/routes/Guestbook.ts +38 -13
  69. package/package.json +2 -2
  70. package/src/cli/index.ts +14 -1
  71. package/src/client/index.ts +2 -0
  72. package/src/client/rpc.ts +25 -1
  73. package/src/client/stream/client.ts +107 -0
  74. package/src/compiler/config.ts +15 -7
  75. package/src/compiler/index.ts +43 -18
  76. package/src/compiler/toil-docs.generated.ts +3 -3
  77. package/src/devserver/daemon/index.ts +7 -7
  78. package/src/devserver/daemon/runtime.ts +48 -0
  79. package/src/devserver/db/catalog.ts +9 -13
  80. package/src/devserver/db/database.ts +14 -0
  81. package/src/devserver/db/derives.ts +121 -0
  82. package/src/devserver/db/index.ts +1 -0
  83. package/src/devserver/db/types.ts +6 -0
  84. package/src/devserver/http/proxy.ts +53 -39
  85. package/src/devserver/http/runtime.ts +287 -0
  86. package/src/devserver/index.ts +2 -0
  87. package/src/devserver/production-ipc.ts +63 -0
  88. package/src/devserver/production-worker.ts +83 -0
  89. package/src/devserver/production.ts +706 -0
  90. package/src/devserver/runtime/module.ts +95 -1
  91. package/src/devserver/server.ts +52 -201
  92. package/src/devserver/ssr.ts +23 -3
  93. package/src/devserver/stream/catalog.ts +106 -0
  94. package/src/devserver/stream/host.ts +42 -0
  95. package/src/devserver/stream/index.ts +308 -0
  96. package/src/devserver/stream/manager.ts +163 -0
  97. package/src/devserver/stream/router.ts +101 -0
  98. package/src/devserver/stream/wire.ts +58 -0
  99. package/src/devserver/stream/ws.ts +76 -0
  100. package/src/devserver/wasm/surface.ts +5 -7
  101. package/test/built-ssr.test.ts +98 -0
  102. package/test/daemon-build.test.ts +15 -7
  103. package/test/daemon-catalog.test.ts +17 -8
  104. package/test/devserver-database.test.ts +8 -8
  105. package/test/devserver.test.ts +20 -4
  106. package/test/example-guestbook.test.ts +8 -5
  107. package/test/fixtures/stream-echo.ts +26 -0
  108. package/test/fixtures/stream-gate.ts +24 -0
  109. package/test/fixtures/stream-trap.ts +18 -0
  110. package/test/rpc-bignum-wire.test.ts +8 -8
  111. package/test/stream-emulation.test.ts +394 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Transport adapter that bridges ONE dev WebSocket connection to the {@link StreamDevHost} session
3
+ * driver. It is the per-socket glue the dev WS endpoint instantiates: socket-open -> `acceptUpgrade`
4
+ * (close on a `@connect`/gate reject), inbound frame -> `dispatch` (send reply frames back, or close
5
+ * on a reject/trap close code), socket-close -> `@close` + drop. It owns NO transport itself - the
6
+ * caller passes a {@link StreamWsTransport} of `send`/`close` callbacks - so it is unit-testable
7
+ * without a live socket and reusable across whatever WS/WebTransport the endpoint ends up speaking.
8
+ *
9
+ * The endpoint that wires hyper-express `app.ws` to this (the URL convention + coexistence with the
10
+ * Vite-HMR catch-all proxy + a `streamWasmFile` config) is the remaining live-server step; this is the
11
+ * transport-agnostic core it drives.
12
+ */
13
+
14
+ import type { StreamDevHost } from './manager.js';
15
+
16
+ /** The socket-side primitives this adapter needs: send one binary frame, and close with a code. */
17
+ export interface StreamWsTransport {
18
+ /** Send one outbound binary frame to the client. */
19
+ send(frame: Buffer): void;
20
+ /** Close the connection with a `0x02xx` stream close code. */
21
+ close(code: number): void;
22
+ }
23
+
24
+ export class StreamWsSession {
25
+ private open = false;
26
+
27
+ constructor(
28
+ private readonly host: StreamDevHost,
29
+ private readonly connId: string,
30
+ private readonly authority: string,
31
+ private readonly path: string,
32
+ private readonly transport: StreamWsTransport,
33
+ ) {}
34
+
35
+ /** Whether the connection is accepted + live. */
36
+ get isOpen(): boolean {
37
+ return this.open;
38
+ }
39
+
40
+ /**
41
+ * Socket open: accept the upgrade (node gate is the endpoint's job; this drives the box). On a
42
+ * `@connect`/artifact reject the connection is closed with the code and no box is held. Returns
43
+ * whether the connection was accepted.
44
+ */
45
+ onOpen(): boolean {
46
+ const up = this.host.acceptUpgrade(this.connId, this.authority, this.path);
47
+ if (up.kind === 'rejected') {
48
+ this.transport.close(up.code);
49
+ return false;
50
+ }
51
+ this.open = true;
52
+ return true;
53
+ }
54
+
55
+ /** An inbound binary frame: dispatch it; send the reply frames, or close on a reject/trap code. */
56
+ onMessage(inbound: Buffer): void {
57
+ if (!this.open) return;
58
+ const r = this.host.dispatch(this.connId, inbound);
59
+ if (r.kind === 'reply') {
60
+ for (const frame of r.frames) this.transport.send(frame);
61
+ } else if (r.kind === 'close') {
62
+ // A guest reject or a TRAP close: close the socket; the socket-close event runs onClose,
63
+ // which fires @close + drops the box (a no-op if a trap already discarded it).
64
+ this.open = false;
65
+ this.transport.close(r.code);
66
+ }
67
+ // 'noConnection' cannot occur for a live, accepted session.
68
+ }
69
+
70
+ /** Socket close (client-initiated or after our own close): fire `@close` + drop the box. */
71
+ onClose(): void {
72
+ if (!this.open && !this.host.has(this.connId)) return;
73
+ this.open = false;
74
+ this.host.close(this.connId);
75
+ }
76
+ }
@@ -18,9 +18,8 @@
18
18
  * u32 data_coherence_hash
19
19
  * u32 pair_coherence_hash (exactly THREE u32 after build_id, not four)
20
20
  *
21
- * Fail-closed per Part 5's host rule: an ABSENT section is "legacy single
22
- * artifact, load as hot" (NOT a hard reject); a PRESENT-but-unparseable section is
23
- * a corrupt artifact -> do not start that artifact's emulator.
21
+ * Fail-closed per Part 5's host rule: an absent or unparseable section is a
22
+ * corrupt Toil artifact -> do not start that artifact's emulator.
24
23
  */
25
24
 
26
25
  import { DataReader } from 'toiljs/io';
@@ -47,16 +46,15 @@ export interface Surface {
47
46
  readonly pairCoherenceHash: number;
48
47
  }
49
48
 
50
- /** `'absent'` => legacy single artifact (load as hot, no emulators).
51
- * `'invalid'` => present but corrupt (fail closed). Otherwise the parsed surface. */
52
- export function parseSurface(wasm: Buffer): Surface | 'absent' | 'invalid' {
49
+ /** `'invalid'` => absent or corrupt (fail closed). Otherwise the parsed surface. */
50
+ export function parseSurface(wasm: Buffer): Surface | 'invalid' {
53
51
  let sec: Buffer | null;
54
52
  try {
55
53
  sec = customSection(wasm, 'toil.surface');
56
54
  } catch {
57
55
  return 'invalid'; // garbage section table
58
56
  }
59
- if (sec === null) return 'absent';
57
+ if (sec === null) return 'invalid';
60
58
 
61
59
  const r = new DataReader(sec);
62
60
  r.readU16(); // format_version
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { loadBuiltSsrTemplates } from '../src/devserver/production';
8
+ import { assembleSsr, type SsrRoute } from '../src/devserver/ssr';
9
+
10
+ function slotsManifest(tmplLen: number, hash: Buffer): Buffer {
11
+ const buf = Buffer.alloc(46 + 8);
12
+ let o = 0;
13
+ buf.write('TSLT', o, 'ascii');
14
+ o += 4;
15
+ buf.writeUInt16LE(1, o);
16
+ o += 2;
17
+ buf.writeUInt16LE(0, o);
18
+ o += 2;
19
+ buf.writeUInt32LE(tmplLen, o);
20
+ o += 4;
21
+ hash.copy(buf, o);
22
+ o += 32;
23
+ buf.writeUInt16LE(1, o);
24
+ o += 2;
25
+ buf.writeUInt32LE(1, o); // insert after the first byte
26
+ o += 4;
27
+ buf.writeUInt16LE(0, o);
28
+ o += 2;
29
+ buf.writeUInt8(0, o); // text
30
+ o += 1;
31
+ buf.writeUInt8(0, o);
32
+ return buf;
33
+ }
34
+
35
+ function valuesEnvelope(hash: Buffer, value: string): Buffer {
36
+ const valueBytes = Buffer.from(value);
37
+ const buf = Buffer.alloc(2 + 32 + 2 + 2 + 2 + 1 + 4 + valueBytes.length);
38
+ let o = 0;
39
+ buf.writeUInt16LE(200, o);
40
+ o += 2;
41
+ hash.copy(buf, o);
42
+ o += 32;
43
+ buf.writeUInt16LE(0, o); // headers
44
+ o += 2;
45
+ buf.writeUInt16LE(1, o); // slots
46
+ o += 2;
47
+ buf.writeUInt16LE(0, o);
48
+ o += 2;
49
+ buf.writeUInt8(0, o);
50
+ o += 1;
51
+ buf.writeUInt32LE(valueBytes.length, o);
52
+ o += 4;
53
+ valueBytes.copy(buf, o);
54
+ return buf;
55
+ }
56
+
57
+ describe('built SSR templates', () => {
58
+ it('loads the built shell, including production CSS links, from _ssr artifacts', () => {
59
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-built-ssr-'));
60
+ try {
61
+ const dir = path.join(root, '_ssr');
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ const tmpl = Buffer.from(
64
+ '<!doctype html><html><head><link rel="stylesheet" href="/css/style.css"></head><body>A</body></html>',
65
+ );
66
+ const hash = Buffer.alloc(32, 7);
67
+ fs.writeFileSync(
68
+ path.join(dir, 'templates.json'),
69
+ JSON.stringify([{ route: '/x', name: 'x', hash: hash.toString('hex') }]),
70
+ );
71
+ fs.writeFileSync(path.join(dir, 'x.tmpl'), tmpl);
72
+ fs.writeFileSync(path.join(dir, 'x.slots'), slotsManifest(tmpl.length, hash));
73
+
74
+ const templates = loadBuiltSsrTemplates(root);
75
+ expect(templates).toHaveLength(1);
76
+ expect(Buffer.from(templates[0]!.tmpl).toString('utf8')).toContain('/css/style.css');
77
+ expect(Buffer.from(templates[0]!.hash!).equals(hash)).toBe(true);
78
+ expect(templates[0]!.entries).toEqual([{ id: 0, offset: 1 }]);
79
+ } finally {
80
+ fs.rmSync(root, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ it('checks the deployed template hash before splicing production SSR values', () => {
85
+ const hash = Buffer.alloc(32, 1);
86
+ const route: SsrRoute = {
87
+ test: () => true,
88
+ tmpl: Buffer.from('ab'),
89
+ entries: [{ id: 0, offset: 1 }],
90
+ hash,
91
+ };
92
+
93
+ expect(Buffer.from(assembleSsr(route, valuesEnvelope(hash, 'X'))!.html).toString()).toBe(
94
+ 'aXb',
95
+ );
96
+ expect(assembleSsr(route, valuesEnvelope(Buffer.alloc(32, 2), 'X'))).toBeNull();
97
+ });
98
+ });
@@ -9,7 +9,7 @@
9
9
  * passes (one `--targetMode cold`, one `--targetMode hot`) and produces BOTH
10
10
  * `release-hot.wasm` and `release-cold.wasm`; the cold artifact decodes to a
11
11
  * daemon catalog and its `toil.surface` is target_mode = cold.
12
- * - a project with only the legacy request surface keeps the single-artifact
12
+ * - a project with only the default request surface keeps the single-artifact
13
13
  * path (no cold pass, no cold artifact).
14
14
  *
15
15
  * The build invokes the LOCAL toilscript (branch feat/streams-phase0-compiler),
@@ -17,7 +17,15 @@
17
17
  * `node_modules` the same way the dev build resolves it (`require.resolve`).
18
18
  */
19
19
 
20
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readFileSync,
25
+ rmSync,
26
+ symlinkSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
21
29
  import { tmpdir } from 'node:os';
22
30
  import { dirname, join } from 'node:path';
23
31
  import { fileURLToPath } from 'node:url';
@@ -190,8 +198,8 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT))('buildServer two-pass (daemon pro
190
198
  // The cold artifact carries the daemon surface + catalog (decoded byte-for-byte).
191
199
  const coldBytes = readFileSync(cold);
192
200
  const surface = parseSurface(coldBytes);
193
- expect(surface !== 'absent' && surface !== 'invalid' && surface.targetMode).toBe('cold');
194
- expect(surface !== 'absent' && surface !== 'invalid' && surface.flags.daemon).toBe(true);
201
+ expect(surface !== 'invalid' && surface.targetMode).toBe('cold');
202
+ expect(surface !== 'invalid' && surface.flags.daemon).toBe(true);
195
203
 
196
204
  const catalog = parseDaemonCatalog(coldBytes);
197
205
  expect(catalog).not.toBeNull();
@@ -201,12 +209,12 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT))('buildServer two-pass (daemon pro
201
209
  expect(catalog!.tasks[1].schedule.kind).toBe('cron');
202
210
 
203
211
  // A daemon-only project (no request/stream surface) has no hot files, so the hot pass is
204
- // skipped (toilscript would HARD-ERROR a @daemon class under --targetMode hot). The legacy
205
- // single-artifact `release.wasm` is therefore not produced for a pure background worker.
212
+ // skipped (toilscript would HARD-ERROR a @daemon class under --targetMode hot). The default
213
+ // request artifact `release.wasm` is therefore not produced for a pure background worker.
206
214
  expect(existsSync(join(tmp, 'build/server/release.wasm'))).toBe(false);
207
215
  }, 60_000);
208
216
 
209
- it('keeps the single-artifact path for a legacy (no-daemon) project', async () => {
217
+ it('keeps the single-artifact path for a request-only project', async () => {
210
218
  scaffold(LEGACY_SRC, BASE_TOILCONFIG);
211
219
  await buildServer(tmp);
212
220
 
@@ -117,7 +117,13 @@ describe('parseDaemonCatalog (Part 5)', () => {
117
117
  name: 'lateMinute',
118
118
  taskIndex: 0,
119
119
  kind: 1,
120
- cron: { minute: minuteMask, hour: 0xffffff, dom: 0xfffffffe, month: 0x1ffe, dow: 0x7f },
120
+ cron: {
121
+ minute: minuteMask,
122
+ hour: 0xffffff,
123
+ dom: 0xfffffffe,
124
+ month: 0x1ffe,
125
+ dow: 0x7f,
126
+ },
121
127
  },
122
128
  ]);
123
129
  const cat = parseDaemonCatalog(wasmWithSection('toildaemon.catalog', payload));
@@ -173,10 +179,11 @@ function buildSurfaceBytes(opts: {
173
179
  describe('parseSurface (Part 5)', () => {
174
180
  it('decodes a cold daemon surface with exactly three trailing u32 hashes', () => {
175
181
  const flags = 0b000100 | 0b001000; // daemon (bit2) + scheduled (bit3)
176
- const s = parseSurface(wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 1, flags })));
177
- expect(s).not.toBe('absent');
182
+ const s = parseSurface(
183
+ wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 1, flags })),
184
+ );
178
185
  expect(s).not.toBe('invalid');
179
- if (s !== 'absent' && s !== 'invalid') {
186
+ if (s !== 'invalid') {
180
187
  expect(s.targetMode).toBe('cold');
181
188
  expect(s.flags.daemon).toBe(true);
182
189
  expect(s.flags.scheduled).toBe(true);
@@ -188,13 +195,15 @@ describe('parseSurface (Part 5)', () => {
188
195
  });
189
196
 
190
197
  it('decodes a hot surface (target_mode 0)', () => {
191
- const s = parseSurface(wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 0, flags: 1 })));
192
- expect(s !== 'absent' && s !== 'invalid' && s.targetMode).toBe('hot');
198
+ const s = parseSurface(
199
+ wasmWithSection('toil.surface', buildSurfaceBytes({ mode: 0, flags: 1 })),
200
+ );
201
+ expect(s !== 'invalid' && s.targetMode).toBe('hot');
193
202
  });
194
203
 
195
- it("treats an ABSENT section as 'absent' (legacy single artifact, load as hot)", () => {
204
+ it('fails closed when toil.surface is absent', () => {
196
205
  const wasm = wasmWithSection('toildb.catalog', Buffer.from([0x01, 0x00]));
197
- expect(parseSurface(wasm)).toBe('absent');
206
+ expect(parseSurface(wasm)).toBe('invalid');
198
207
  });
199
208
 
200
209
  it("fails closed: a PRESENT but truncated section is 'invalid'", () => {
@@ -92,7 +92,7 @@ function routeKindsSection(routes: readonly (readonly [number, number, string])[
92
92
  return Buffer.concat(chunks);
93
93
  }
94
94
 
95
- function catalogSectionV2(fillMaxWaitMs: number, fillAllowStale: number, replication = 5): Buffer {
95
+ function catalogSectionV1(fillMaxWaitMs: number, fillAllowStale: number, replication = 5): Buffer {
96
96
  const chunks: Buffer[] = [];
97
97
  const u8 = (v: number) => chunks.push(Buffer.from([v & 0xff]));
98
98
  const u16 = (v: number) => {
@@ -111,7 +111,7 @@ function catalogSectionV2(fillMaxWaitMs: number, fillAllowStale: number, replica
111
111
  chunks.push(b);
112
112
  };
113
113
 
114
- u16(2); // catalog version
114
+ u16(1); // catalog version
115
115
  u16(1); // databases
116
116
  str('App');
117
117
  u16(1); // collections
@@ -215,8 +215,8 @@ describe('toildb dev emulator (record family)', () => {
215
215
  expect(imports['data.resolve_collection'](p, l, 16)).toBe(-1070);
216
216
  });
217
217
 
218
- it('parses catalog v2 fill policy and backend replication bytes', () => {
219
- setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV2(7, 0)));
218
+ it('parses catalog v1 fill policy and backend replication bytes', () => {
219
+ setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV1(7, 0)));
220
220
  const { imports, buf, db } = setupRaw();
221
221
  const h = resolve(imports, buf, 'App/users');
222
222
 
@@ -231,9 +231,9 @@ describe('toildb dev emulator (record family)', () => {
231
231
  });
232
232
  });
233
233
 
234
- it('rejects replication policies that require catalog v3 metadata', () => {
234
+ it('rejects replication policies that require explicit policy metadata', () => {
235
235
  for (const replication of [3, 4]) {
236
- setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV2(7, 0, replication)));
236
+ setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV1(7, 0, replication)));
237
237
  const { imports, buf } = setupRaw();
238
238
  const [p, l] = put(buf, 0, 'App/users');
239
239
 
@@ -241,8 +241,8 @@ describe('toildb dev emulator (record family)', () => {
241
241
  }
242
242
  });
243
243
 
244
- it('rejects malformed catalog v2 fill policy', () => {
245
- setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV2(7, 2)));
244
+ it('rejects malformed catalog v1 fill policy', () => {
245
+ setDbCatalog(wasmWithSection('toildb.catalog', catalogSectionV1(7, 2)));
246
246
  const { imports, buf } = setupRaw();
247
247
  const [p, l] = put(buf, 0, 'App/users');
248
248
 
@@ -4,6 +4,7 @@
4
4
  * into the example project's ToilScript-compiled server wasm.
5
5
  */
6
6
  import fs from 'node:fs';
7
+ import os from 'node:os';
7
8
  import path from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
9
10
 
@@ -15,6 +16,7 @@ import {
15
16
  unpackHandleResult,
16
17
  WasmServerModule,
17
18
  } from '../src/devserver/index.js';
19
+ import { resolveStaticFile } from '../src/devserver/http/runtime.js';
18
20
 
19
21
  const EXAMPLE_WASM = path.resolve(
20
22
  path.dirname(fileURLToPath(import.meta.url)),
@@ -121,10 +123,7 @@ describe('response envelope decoding', () => {
121
123
  /status 0/,
122
124
  );
123
125
  // Claim a huge header name with too few bytes behind it.
124
- const bad = Buffer.concat([
125
- Buffer.from([200, 0, 1, 0, 255, 255, 0, 0]),
126
- Buffer.from('hi'),
127
- ]);
126
+ const bad = Buffer.concat([Buffer.from([200, 0, 1, 0, 255, 255, 0, 0]), Buffer.from('hi')]);
128
127
  expect(() => decodeResponseEnvelope(bad)).toThrow(/truncated/);
129
128
  });
130
129
  });
@@ -140,6 +139,23 @@ describe('handle() result unpacking', () => {
140
139
  });
141
140
  });
142
141
 
142
+ describe('static file resolver', () => {
143
+ it('resolves absolute request paths inside the static root', () => {
144
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-static-'));
145
+ const file = path.join(root, 'assets', 'app.css');
146
+ try {
147
+ fs.mkdirSync(path.dirname(file), { recursive: true });
148
+ fs.writeFileSync(file, 'body{}');
149
+
150
+ expect(resolveStaticFile(root, '/assets/app.css')).toBe(file);
151
+ expect(resolveStaticFile(root, '/missing.css')).toBeNull();
152
+ expect(resolveStaticFile(root, '/../app.css')).toBeNull();
153
+ } finally {
154
+ fs.rmSync(root, { recursive: true, force: true });
155
+ }
156
+ });
157
+ });
158
+
143
159
  describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('dispatch into the example server wasm', () => {
144
160
  const load = (): WasmServerModule => {
145
161
  const m = new WasmServerModule(EXAMPLE_WASM);
@@ -64,17 +64,20 @@ describe.skipIf(!haveWasm)('guestbook demo: ToilDB events + counter persist acro
64
64
  expect(json(r).total).toBe('1');
65
65
 
66
66
  // A brand-new wasm instance - its memory is empty - still sees the prior
67
- // signature, because it lives in ToilDB, not module state.
67
+ // signature, because it lives in ToilDB, not module state. The action
68
+ // acks with the running total; the entries list is served by the GET.
68
69
  r = sign(load(), 'Linus', 'second');
69
- const v = json(r);
70
+ expect(json(r).total).toBe('2');
71
+
72
+ // The newest entries are served by GET /guestbook from the materialized
73
+ // view that the @derive republishes after each signature (events.latest
74
+ // is a scan, barred in the request handlers, so it runs in the derive).
75
+ const v = json(list(load()));
70
76
  expect(v.total).toBe('2');
71
77
  expect(v.entries.length).toBe(2);
72
78
  expect(v.entries[0].author).toBe('Linus'); // events.latest is newest-first
73
79
  expect(v.entries[1].author).toBe('Ada');
74
80
  expect(v.entries[1].message).toBe('first!');
75
-
76
- // A read-only GET on yet another instance sees the same persisted state.
77
- expect(json(list(load())).total).toBe('2');
78
81
  });
79
82
 
80
83
  // End-to-end proof that the `server/migrations/GuestEntry.migration.ts` demo
@@ -0,0 +1,26 @@
1
+ // Dev-stream emulation fixture: a `@stream('echo')` whose raw `@message` bridge echoes the inbound
2
+ // bytes back through the egress ring, rejects an 'X'-prefixed frame (0x0210), and `empty()`s a
3
+ // zero-length frame. Compiled by the dev test with the LOCAL toilscript (`--targetMode hot`), then
4
+ // driven through `DevStreamBox`. Mirrors toil-backend's `tests/fixtures/stream/echo_src.ts`.
5
+
6
+ let __count: i32 = 0;
7
+
8
+ // Observable so the @message hook's residency can be asserted across events.
9
+ export function messageCount(): i32 {
10
+ return __count;
11
+ }
12
+
13
+ @stream('echo')
14
+ class Echo {
15
+ @message reply(p: StreamPacket): StreamOutbound {
16
+ __count = __count + 1;
17
+ const n = p.length;
18
+ if (n == 0) return StreamOutbound.empty();
19
+ if (p.at(0) == 0x58) return StreamOutbound.reject(0x0210); // 'X' -> reject
20
+ return StreamOutbound.reply(p.bytes());
21
+ }
22
+ }
23
+
24
+ export function probe(): i32 {
25
+ return 1;
26
+ }
@@ -0,0 +1,24 @@
1
+ // Dev @connect-bridge fixture: a `@stream('gate')` whose `@connect(c: StreamInbound): StreamOutbound`
2
+ // reads the host-written connect context (the path) and REJECTS "/blocked" with 0x0211, ACCEPTING any
3
+ // other path; a "/greet" path stages an egress frame DURING @connect (the host must clear it so it does
4
+ // not contaminate the first @message reply). A @message echoes. Mirrors toil-backend's connect_src.ts;
5
+ // exercises the whole @connect bridge (stream_info block -> StreamInbound.path() -> accept/reject).
6
+
7
+ @stream('gate')
8
+ class Gate {
9
+ @connect onConnect(c: StreamInbound): StreamOutbound {
10
+ if (c.path() == "/blocked") return StreamOutbound.reject(0x0211);
11
+ if (c.path() == "/greet") {
12
+ const g = new Uint8Array(3);
13
+ g[0] = 0x47; g[1] = 0x48; g[2] = 0x49; // "GHI"
14
+ StreamOutbound.reply(g);
15
+ return StreamOutbound.accept();
16
+ }
17
+ return StreamOutbound.accept();
18
+ }
19
+ @message reply(p: StreamPacket): StreamOutbound {
20
+ return StreamOutbound.reply(p.bytes());
21
+ }
22
+ }
23
+
24
+ export function probe(): i32 { return 1; }
@@ -0,0 +1,18 @@
1
+ // Dev trap fixture: a `@stream('trap')` whose `@message` deliberately TRAPS (the wasm `unreachable`
2
+ // instruction). The dev has no gas-metering middleware, so this stands in for the edge's gas-kill: a
3
+ // real trap makes the dispatch throw, which StreamDevHost turns into a STREAM_HOOK_TRAPPED close that
4
+ // discards the poisoned box (mirrors toil-backend's poisoned-box containment, 05 7.4).
5
+ //
6
+ // The trap sits behind an always-true guard so the `return` stays reachable to the type checker.
7
+
8
+ @stream('trap')
9
+ class Trap {
10
+ @message boom(p: StreamPacket): StreamOutbound {
11
+ if (p.length >= 0) {
12
+ unreachable();
13
+ }
14
+ return StreamOutbound.empty();
15
+ }
16
+ }
17
+
18
+ export function probe(): i32 { return 1; }
@@ -7,7 +7,7 @@
7
7
  * Compiles test/fixtures/bignum-wire/spec.ts with the installed toilscript (so it
8
8
  * exercises the published compiler + generated client, not a hand-written stub), then
9
9
  * imports the generated TS client and asserts the wire shape both directions, including
10
- * values far above 2^53 and the legacy limb-array shape older servers emitted.
10
+ * values far above 2^53.
11
11
  */
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import fs from 'node:fs';
@@ -24,6 +24,9 @@ const codec = path.join(here, '..', 'src', 'io', 'codec.ts');
24
24
 
25
25
  /** Resolves the installed toilscript CLI entry (no PATH / .bin assumptions). */
26
26
  function toilscriptBin(): string {
27
+ if (process.env.TOILSCRIPT_BIN) return process.env.TOILSCRIPT_BIN;
28
+ const sibling = path.resolve(here, '..', '..', 'toilscript', 'bin', 'toilscript.js');
29
+ if (fs.existsSync(sibling)) return sibling;
27
30
  const pkgPath = require.resolve('toilscript/package.json');
28
31
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin?: Record<string, string> };
29
32
  const binRel = pkg.bin?.toilscript;
@@ -83,6 +86,10 @@ beforeAll(async () => {
83
86
  { encoding: 'utf8' },
84
87
  );
85
88
  if (res.status !== 0) throw new Error('toilscript compile failed:\n' + res.stderr);
89
+ const generatedSource = fs.readFileSync(mod, 'utf8');
90
+ const removedHelper = ['__toil', 'Unlimb'].join('');
91
+ expect(generatedSource).toContain('__toilBigInt');
92
+ expect(generatedSource).not.toContain(removedHelper);
86
93
  const gen = (await import(pathToFileURL(mod).href)) as {
87
94
  Wallet: WalletStatic;
88
95
  Account: AccountStatic;
@@ -142,13 +149,6 @@ describe('generated client bignum JSON wire format', () => {
142
149
  expect(back.d).toBe(BigInt('-' + huge));
143
150
  });
144
151
 
145
- it('still revives the legacy little-endian limb-array shape (back-compat)', () => {
146
- // u256 [5,0,4,0] little-endian = 5 + 4*2^128.
147
- const w = Wallet.fromJSONValue({ c: [5, 0, 4, 0], a: [9, 1] });
148
- expect(w.c).toBe(2n ** 130n + 5n);
149
- expect(w.a).toBe(2n ** 64n + 9n);
150
- });
151
-
152
152
  it('recurses into nested @data and arrays of bignums', () => {
153
153
  const a = new Account();
154
154
  a.main.c = BigInt(huge);