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,394 @@
1
+ /**
2
+ * Dev STREAM emulation end-to-end (Phase 4). Compiles real `@stream` fixtures with the LOCAL toilscript
3
+ * (`--targetMode hot`), then drives `DevStreamBox` + `StreamDevHost` and asserts the dev runtime mirrors
4
+ * the production edge (`toil-backend` `src/wasm/stream`) BYTE-FOR-BYTE: the `@message` ring bridge
5
+ * (echo / reject / empty), the `@connect` info-block bridge (path-based accept/reject + egress clear),
6
+ * and the session driver (accept / dispatch / trap-close / lifecycle). The dev mirror of toil-backend's
7
+ * message_bridge + @connect + hostile-isolation tests.
8
+ */
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ import { Server } from '@dacely/hyper-express';
17
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
18
+
19
+ import { makeStreamClient } from '../src/client/stream/client.js';
20
+ import { matchStreamRoute, parseStreamCatalog } from '../src/devserver/stream/catalog.js';
21
+ import { wireStreams } from '../src/devserver/stream/wire.js';
22
+ import { DevStreamBox } from '../src/devserver/stream/index.js';
23
+ import { StreamDevHost } from '../src/devserver/stream/manager.js';
24
+ import {
25
+ StreamRouter,
26
+ type StreamUpgradeContext,
27
+ type StreamWs,
28
+ } from '../src/devserver/stream/router.js';
29
+ import { StreamWsSession, type StreamWsTransport } from '../src/devserver/stream/ws.js';
30
+
31
+ const here = dirname(fileURLToPath(import.meta.url));
32
+ // The LOCAL toilscript build (the @message + @connect bridge codegen); the published dep predates it.
33
+ const LOCAL_TOILSCRIPT_BIN = join(here, '..', '..', 'toilscript', 'bin', 'toilscript.js');
34
+
35
+ let tmp: string;
36
+ let ECHO_PATH: string;
37
+ let GATE_PATH: string;
38
+ let TRAP_PATH: string;
39
+ let ECHO: Buffer;
40
+ let GATE: Buffer;
41
+
42
+ function compile(srcName: string): { path: string; wasm: Buffer } {
43
+ const src = join(here, 'fixtures', srcName);
44
+ const out = join(tmp, srcName.replace(/\.ts$/, '.wasm'));
45
+ const r = spawnSync(
46
+ 'node',
47
+ [LOCAL_TOILSCRIPT_BIN, src, '-o', out, '--targetMode', 'hot', '--runtime', 'stub'],
48
+ { encoding: 'utf8' },
49
+ );
50
+ if (r.status !== 0) {
51
+ throw new Error(`toilscript compile ${srcName} failed (${String(r.status)}):\n${r.stderr}${r.stdout}`);
52
+ }
53
+ return { path: out, wasm: readFileSync(out) };
54
+ }
55
+
56
+ beforeAll(() => {
57
+ tmp = mkdtempSync(join(tmpdir(), 'toiljs-stream-'));
58
+ const echo = compile('stream-echo.ts');
59
+ ECHO_PATH = echo.path;
60
+ ECHO = echo.wasm;
61
+ const gate = compile('stream-gate.ts');
62
+ GATE_PATH = gate.path;
63
+ GATE = gate.wasm;
64
+ TRAP_PATH = compile('stream-trap.ts').path;
65
+ });
66
+
67
+ afterAll(() => {
68
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
69
+ });
70
+
71
+ describe('dev stream box: the @message ring bridge', () => {
72
+ const id = 0x0000_0007_0000_0005n;
73
+
74
+ it('loads a hot stream artifact with the ring runtime', () => {
75
+ expect(DevStreamBox.load(ECHO).hasRings).toBe(true);
76
+ });
77
+
78
+ it('echoes / rejects / empties through the ring, persisting state across events', () => {
79
+ const box = DevStreamBox.load(ECHO);
80
+ expect(box.onConnect(id, 'localhost', '/').kind).toBe('accept'); // echo declares no @connect
81
+ expect(box.onMessage(id, Buffer.from('hello'))).toEqual({
82
+ kind: 'reply',
83
+ frames: [Buffer.from('hello')],
84
+ });
85
+ expect(box.onMessage(id, Buffer.from('second frame'))).toEqual({
86
+ kind: 'reply',
87
+ frames: [Buffer.from('second frame')],
88
+ });
89
+ expect(box.onMessage(id, Buffer.from('Xdrop'))).toEqual({ kind: 'reject', code: 0x0210 });
90
+ expect(box.onMessage(id, Buffer.from(''))).toEqual({ kind: 'reply', frames: [] });
91
+ });
92
+
93
+ it('rejects a non-stream artifact (fails closed)', () => {
94
+ expect(() => DevStreamBox.load(Buffer.from('\0asm\x01\0\0\0', 'binary'))).toThrow();
95
+ });
96
+ });
97
+
98
+ describe('dev stream box: the @connect info-block bridge', () => {
99
+ const id = 0x11n;
100
+
101
+ it('reads the connect path and rejects /blocked while accepting others', () => {
102
+ const box = DevStreamBox.load(GATE);
103
+ expect(box.hasConnectBridge).toBe(true);
104
+ expect(box.onConnect(id, 'acme.toil', '/blocked')).toEqual({ kind: 'reject', code: 0x0211 });
105
+
106
+ const ok = DevStreamBox.load(GATE);
107
+ expect(ok.onConnect(id, 'acme.toil', '/room/42')).toEqual({ kind: 'accept' });
108
+ // An accepted connection is usable: its @message echoes.
109
+ expect(ok.onMessage(id, Buffer.from('hi'))).toEqual({
110
+ kind: 'reply',
111
+ frames: [Buffer.from('hi')],
112
+ });
113
+ });
114
+
115
+ it('clears @connect-staged egress so the first @message reply is clean', () => {
116
+ const box = DevStreamBox.load(GATE);
117
+ // /greet stages "GHI" during @connect; the host clears it on accept.
118
+ expect(box.onConnect(id, 'acme.toil', '/greet')).toEqual({ kind: 'accept' });
119
+ // The first @message must see ONLY its own reply, never the stale "GHI".
120
+ expect(box.onMessage(id, Buffer.from('hi'))).toEqual({
121
+ kind: 'reply',
122
+ frames: [Buffer.from('hi')],
123
+ });
124
+ });
125
+ });
126
+
127
+ describe('dev stream session driver (StreamDevHost)', () => {
128
+ it('accepts, dispatches, and closes - mirroring StreamWorker', () => {
129
+ const host = new StreamDevHost(ECHO_PATH);
130
+ expect(host.acceptUpgrade('c1', 'acme.toil', '/').kind).toBe('accepted');
131
+ expect(host.activeConnections).toBe(1);
132
+ expect(host.dispatch('c1', Buffer.from('one'))).toEqual({
133
+ kind: 'reply',
134
+ frames: [Buffer.from('one')],
135
+ });
136
+ // A guest reject -> close with the 0x02xx code.
137
+ expect(host.dispatch('c1', Buffer.from('Xstop'))).toEqual({ kind: 'close', code: 0x0210 });
138
+ // A frame for an unknown connection.
139
+ expect(host.dispatch('ghost', Buffer.from('x'))).toEqual({ kind: 'noConnection' });
140
+ host.close('c1');
141
+ expect(host.activeConnections).toBe(0);
142
+ });
143
+
144
+ it('honors a @connect reject at the upgrade (no box registered)', () => {
145
+ const host = new StreamDevHost(GATE_PATH);
146
+ expect(host.acceptUpgrade('c1', 'acme.toil', '/blocked')).toEqual({
147
+ kind: 'rejected',
148
+ code: 0x0211,
149
+ });
150
+ expect(host.activeConnections).toBe(0);
151
+ expect(host.acceptUpgrade('c2', 'acme.toil', '/ok').kind).toBe('accepted');
152
+ expect(host.dispatch('c2', Buffer.from('hi'))).toEqual({
153
+ kind: 'reply',
154
+ frames: [Buffer.from('hi')],
155
+ });
156
+ });
157
+
158
+ it('trap-closes a hostile @message and discards only its box', () => {
159
+ const host = new StreamDevHost(TRAP_PATH);
160
+ host.acceptUpgrade('h1', 'acme.toil', '/');
161
+ host.acceptUpgrade('h2', 'acme.toil', '/');
162
+ expect(host.activeConnections).toBe(2);
163
+ // h1's @message TRAPS -> STREAM_HOOK_TRAPPED close, its box discarded; h2 is untouched.
164
+ expect(host.dispatch('h1', Buffer.from('boom'))).toEqual({ kind: 'close', code: 0x0200 });
165
+ expect(host.has('h1')).toBe(false);
166
+ expect(host.has('h2')).toBe(true);
167
+ expect(host.activeConnections).toBe(1);
168
+ });
169
+
170
+ it('throws on a duplicate open', () => {
171
+ const host = new StreamDevHost(ECHO_PATH);
172
+ host.acceptUpgrade('c1', 'acme.toil', '/');
173
+ expect(() => host.acceptUpgrade('c1', 'acme.toil', '/')).toThrow();
174
+ });
175
+ });
176
+
177
+ describe('dev stream WS session adapter (StreamWsSession)', () => {
178
+ function makeTransport(): { sent: Buffer[]; closed: number[]; t: StreamWsTransport } {
179
+ const sent: Buffer[] = [];
180
+ const closed: number[] = [];
181
+ return {
182
+ sent,
183
+ closed,
184
+ t: { send: (f: Buffer) => sent.push(f), close: (c: number) => closed.push(c) },
185
+ };
186
+ }
187
+
188
+ it('accepts, echoes a frame back, and tears down', () => {
189
+ const host = new StreamDevHost(ECHO_PATH);
190
+ const { sent, closed, t } = makeTransport();
191
+ const s = new StreamWsSession(host, 'ws1', 'acme.toil', '/', t);
192
+ expect(s.onOpen()).toBe(true);
193
+ expect(s.isOpen).toBe(true);
194
+ s.onMessage(Buffer.from('hi'));
195
+ expect(sent).toEqual([Buffer.from('hi')]);
196
+ expect(closed).toEqual([]);
197
+ s.onClose();
198
+ expect(host.activeConnections).toBe(0);
199
+ });
200
+
201
+ it('closes the socket with the code on a guest reject, then fires @close on socket close', () => {
202
+ const host = new StreamDevHost(ECHO_PATH);
203
+ const { closed, t } = makeTransport();
204
+ const s = new StreamWsSession(host, 'ws1', 'acme.toil', '/', t);
205
+ s.onOpen();
206
+ s.onMessage(Buffer.from('Xstop')); // guest reject -> close 0x0210
207
+ expect(closed).toEqual([0x0210]);
208
+ expect(s.isOpen).toBe(false);
209
+ s.onClose();
210
+ expect(host.activeConnections).toBe(0);
211
+ });
212
+
213
+ it('closes a @connect-rejected upgrade without holding a box', () => {
214
+ const host = new StreamDevHost(GATE_PATH);
215
+ const { closed, t } = makeTransport();
216
+ const s = new StreamWsSession(host, 'ws1', 'acme.toil', '/blocked', t);
217
+ expect(s.onOpen()).toBe(false);
218
+ expect(closed).toEqual([0x0211]);
219
+ expect(host.activeConnections).toBe(0);
220
+ });
221
+ });
222
+
223
+ describe('dev stream catalog (toilstream.catalog route table, doc 08 3.1/4.2)', () => {
224
+ it('parses the @stream route table and matches routes (query stripped)', () => {
225
+ const cat = parseStreamCatalog(ECHO);
226
+ expect(cat.size).toBe(1);
227
+ const def = [...cat.values()][0];
228
+ expect(def.route.length).toBeGreaterThan(0);
229
+ expect(def.hooks.message).toBe(true);
230
+ expect(def.scope).toBe('regional'); // declared_scope default
231
+ expect(def.messageMode).toBe('raw'); // the raw @message bridge
232
+ // matchRoute (4.2): exact match, query stripped; a non-route misses (-> proxied to Vite).
233
+ expect(matchStreamRoute(cat, def.route)).toBe(def);
234
+ expect(matchStreamRoute(cat, `${def.route}?x=1`)).toBe(def);
235
+ expect(matchStreamRoute(cat, '/definitely-not-a-stream')).toBeNull();
236
+ });
237
+ });
238
+
239
+ /** A minimal hyper-express `Websocket` mock for the router (records send/close, replays events). */
240
+ class MockWs implements StreamWs {
241
+ readonly sent: Buffer[] = [];
242
+ readonly closed: number[] = [];
243
+ private msgCb?: (m: Buffer, b: boolean) => void;
244
+ private closeCb?: () => void;
245
+ send(d: Buffer, _isBinary: boolean): void {
246
+ this.sent.push(d);
247
+ }
248
+ close(c: number): void {
249
+ this.closed.push(c);
250
+ }
251
+ on(event: 'message', cb: (m: Buffer, b: boolean) => void): void;
252
+ on(event: 'close', cb: () => void): void;
253
+ on(event: 'message' | 'close', cb: ((m: Buffer, b: boolean) => void) | (() => void)): void {
254
+ if (event === 'message') this.msgCb = cb as (m: Buffer, b: boolean) => void;
255
+ else this.closeCb = cb as () => void;
256
+ }
257
+ emitMessage(m: Buffer): void {
258
+ this.msgCb?.(m, true);
259
+ }
260
+ emitClose(): void {
261
+ this.closeCb?.();
262
+ }
263
+ }
264
+
265
+ describe('dev stream router (doc 08 4.1/4.2)', () => {
266
+ it('matches @stream routes and bridges a socket to a resident box', () => {
267
+ const router = new StreamRouter(ECHO_PATH);
268
+ const route = [...parseStreamCatalog(ECHO).keys()][0];
269
+ expect(router.matchRoute(route)).not.toBeNull();
270
+ expect(router.matchRoute(`${route}?x=1`)).not.toBeNull(); // query stripped
271
+ expect(router.matchRoute('/not-a-stream')).toBeNull(); // -> proxied to Vite
272
+
273
+ const ws = new MockWs();
274
+ const ctx: StreamUpgradeContext = { kind: 'stream', route, url: route, authority: 'acme.toil' };
275
+ router.onUpgrade(ws, ctx);
276
+ expect(router.activeConnections).toBe(1);
277
+ ws.emitMessage(Buffer.from('hi'));
278
+ expect(ws.sent).toEqual([Buffer.from('hi')]); // @message echoed back over the socket
279
+ ws.emitClose();
280
+ expect(router.activeConnections).toBe(0); // @close fired + box dropped
281
+ });
282
+
283
+ it('closes the socket with the code on a @connect reject', () => {
284
+ const router = new StreamRouter(GATE_PATH);
285
+ const route = [...parseStreamCatalog(GATE).keys()][0];
286
+ const ws = new MockWs();
287
+ // The gate rejects path "/blocked"; the upgrade's url carries the connect path.
288
+ router.onUpgrade(ws, { kind: 'stream', route, url: '/blocked', authority: 'acme.toil' });
289
+ expect(ws.closed).toEqual([0x0211]);
290
+ expect(router.activeConnections).toBe(0); // rejected -> no resident box
291
+ });
292
+ });
293
+
294
+ describe('dev stream LIVE round-trip (wireStreams over a real WebSocket)', () => {
295
+ it('echoes a binary frame end-to-end through app.upgrade + app.ws', async () => {
296
+ const router = new StreamRouter(ECHO_PATH);
297
+ const route = [...parseStreamCatalog(ECHO).keys()][0];
298
+ const app = new Server();
299
+ // A dummy Vite target: a @stream-route upgrade never touches it (it goes to the StreamRouter).
300
+ wireStreams(app, { host: '127.0.0.1', port: 65535 }, router);
301
+
302
+ const PORT = 49317;
303
+ await app.listen(PORT);
304
+ try {
305
+ const echoed = await new Promise<Buffer>((resolve, reject) => {
306
+ const ws = new WebSocket(`ws://127.0.0.1:${String(PORT)}${route}`);
307
+ ws.binaryType = 'arraybuffer';
308
+ const timer = setTimeout(() => {
309
+ reject(new Error('no echo within 3s'));
310
+ }, 3000);
311
+ ws.onopen = (): void => {
312
+ ws.send(new Uint8Array([0x68, 0x69])); // "hi"
313
+ };
314
+ ws.onmessage = (ev: MessageEvent): void => {
315
+ clearTimeout(timer);
316
+ resolve(Buffer.from(ev.data as ArrayBuffer));
317
+ ws.close();
318
+ };
319
+ ws.onerror = (): void => {
320
+ clearTimeout(timer);
321
+ reject(new Error('websocket error'));
322
+ };
323
+ });
324
+ expect(echoed).toEqual(Buffer.from('hi'));
325
+ } finally {
326
+ app.close();
327
+ }
328
+ });
329
+ });
330
+
331
+ describe('Server.Stream client (doc 08 8.2 makeStreamClient)', () => {
332
+ it('connects + echoes through a real WebSocket end to end', async () => {
333
+ const router = new StreamRouter(ECHO_PATH);
334
+ const route = [...parseStreamCatalog(ECHO).keys()][0];
335
+ const app = new Server();
336
+ wireStreams(app, { host: '127.0.0.1', port: 65535 }, router);
337
+
338
+ const PORT = 49318;
339
+ await app.listen(PORT);
340
+ try {
341
+ // The generated client would call makeStreamClient({ Echo: route }) -> globalThis.__toilStream.
342
+ const stream = makeStreamClient({ Echo: route }, `ws://127.0.0.1:${String(PORT)}`);
343
+ const channel = await stream.Echo.connect();
344
+ const echoed = await new Promise<Uint8Array>((resolve, reject) => {
345
+ const timer = setTimeout(() => {
346
+ reject(new Error('no echo within 3s'));
347
+ }, 3000);
348
+ channel.onMessage((d) => {
349
+ clearTimeout(timer);
350
+ resolve(d);
351
+ });
352
+ channel.send(new Uint8Array([0x68, 0x69])); // "hi"
353
+ });
354
+ expect(Buffer.from(echoed)).toEqual(Buffer.from('hi'));
355
+ channel.close();
356
+ } finally {
357
+ app.close();
358
+ }
359
+ });
360
+
361
+ it('encodes a typed @data message on send (doc 03 2.5)', async () => {
362
+ const router = new StreamRouter(ECHO_PATH);
363
+ const route = [...parseStreamCatalog(ECHO).keys()][0];
364
+ const app = new Server();
365
+ wireStreams(app, { host: '127.0.0.1', port: 65535 }, router);
366
+
367
+ const PORT = 49319;
368
+ await app.listen(PORT);
369
+ try {
370
+ // A typed class: the encoder serializes { text } -> bytes (stands in for ChatMsg.encode()).
371
+ const encode = (m: { text: string }): Uint8Array => new TextEncoder().encode(m.text);
372
+ const stream = makeStreamClient({ Echo: route }, `ws://127.0.0.1:${String(PORT)}`, {
373
+ Echo: encode,
374
+ });
375
+ const channel = await stream.Echo.connect();
376
+ const echoed = await new Promise<Uint8Array>((resolve, reject) => {
377
+ const timer = setTimeout(() => {
378
+ reject(new Error('no echo within 3s'));
379
+ }, 3000);
380
+ channel.onMessage((d) => {
381
+ clearTimeout(timer);
382
+ resolve(d);
383
+ });
384
+ // Send a TYPED message; the channel must encode it before the raw WS send.
385
+ (channel.send as unknown as (m: { text: string }) => void)({ text: 'typed' });
386
+ });
387
+ // The echo server returns the raw bytes it received -> proves the encoder ran on send.
388
+ expect(Buffer.from(echoed)).toEqual(Buffer.from('typed'));
389
+ channel.close();
390
+ } finally {
391
+ app.close();
392
+ }
393
+ });
394
+ });