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