@robota-sdk/agent-transport-ws 3.0.0-beta.78 → 3.0.0-beta.81
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/README.md +14 -4
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +161 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +124 -175
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +36 -17
- package/src/__tests__/outbound-delivery-carrier.test.ts +195 -0
- package/src/__tests__/payload-channels.test.ts +233 -0
- package/src/__tests__/ws-payload-channel.e2e.test.ts +255 -0
- package/src/__tests__/ws-session-delivery.test.ts +86 -0
- package/src/__tests__/ws-transport-auth.test.ts +220 -0
- package/src/__tests__/ws-transport-lifecycle.test.ts +135 -0
- package/src/__tests__/ws-transport.test.ts +69 -5
- package/src/index.ts +5 -5
- package/src/payload-channels.ts +204 -0
- package/src/ws-connection-guards.ts +104 -0
- package/src/ws-message-data.ts +8 -0
- package/src/ws-session-delivery.ts +109 -0
- package/src/ws-transport-config.ts +114 -0
- package/src/ws-transport-configurable.ts +217 -40
- package/src/ws-transport.ts +60 -12
- package/src/__tests__/ws-handler.test.ts +0 -429
- package/src/ws-background-messages.ts +0 -170
- package/src/ws-handler.ts +0 -287
- package/src/ws-protocol.ts +0 -78
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { WsTransport } from '../ws-transport-configurable.js';
|
|
7
|
+
|
|
8
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GUI-002 TC-03 — required loopback auth token. A configured token MUST be presented (query param or
|
|
12
|
+
* `Sec-WebSocket-Protocol` subprotocol) or the socket is closed BEFORE any session data.
|
|
13
|
+
*
|
|
14
|
+
* SEC-001 — secure by default: when NO token is configured, the transport AUTO-MINTS one (`resolvedToken`),
|
|
15
|
+
* so an unauthenticated connection is rejected; `{ open: true }` is the discouraged opt-out. Plus
|
|
16
|
+
* defense-in-depth: the WS upgrade rejects a non-loopback `Host` (DNS rebinding) and a non-allowed browser
|
|
17
|
+
* `Origin` at the handshake.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function mockSession(): IInteractiveSession {
|
|
21
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
22
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
23
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
24
|
+
on: vi.fn(),
|
|
25
|
+
off: vi.fn(),
|
|
26
|
+
submit: vi.fn(),
|
|
27
|
+
abort: vi.fn(),
|
|
28
|
+
cancelQueue: vi.fn(),
|
|
29
|
+
executeCommand: vi.fn(),
|
|
30
|
+
resolvePermission: vi.fn(),
|
|
31
|
+
resolveAsk: vi.fn(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const started: WsTransport[] = [];
|
|
36
|
+
|
|
37
|
+
interface IStartConfig {
|
|
38
|
+
token?: string;
|
|
39
|
+
open?: boolean;
|
|
40
|
+
/** SEC-008: required alongside `open` — the transport refuses an opt-out nobody explained. */
|
|
41
|
+
openReason?: string;
|
|
42
|
+
allowedHosts?: readonly string[];
|
|
43
|
+
allowedOrigins?: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Start a transport and return its port + the resolved token (auto-minted when none was configured). */
|
|
47
|
+
async function startOn(config: IStartConfig = {}): Promise<{ port: number; token?: string }> {
|
|
48
|
+
// Use a high, unlikely-occupied base port; retry covers rare collisions.
|
|
49
|
+
const port = 17000 + Math.floor((started.length + 1) * 7);
|
|
50
|
+
const t = new WsTransport({ port, maxRetries: 30, ...config });
|
|
51
|
+
t.attach(mockSession());
|
|
52
|
+
await t.start();
|
|
53
|
+
started.push(t);
|
|
54
|
+
return { port, token: t.resolvedToken };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
while (started.length) await started.pop()!.stop();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Connect and resolve with the outcome: `'messages'` if a `messages` frame arrives first, or `'closed'`
|
|
63
|
+
* with the close code if the socket closes before any frame. 1.5s guard so a hang fails loudly.
|
|
64
|
+
*/
|
|
65
|
+
function probe(
|
|
66
|
+
port: number,
|
|
67
|
+
opts: { token?: string; subprotocol?: string; headers?: Record<string, string> } = {},
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
const url = `ws://127.0.0.1:${port}${opts.token ? `?token=${encodeURIComponent(opts.token)}` : ''}`;
|
|
70
|
+
const wsOpts = opts.headers ? { headers: opts.headers } : {};
|
|
71
|
+
const ws = opts.subprotocol
|
|
72
|
+
? new WebSocket(url, opts.subprotocol, wsOpts)
|
|
73
|
+
: new WebSocket(url, wsOpts);
|
|
74
|
+
return new Promise<string>((resolve) => {
|
|
75
|
+
const done = (v: string): void => {
|
|
76
|
+
try {
|
|
77
|
+
ws.close();
|
|
78
|
+
} catch {
|
|
79
|
+
/* already closing */
|
|
80
|
+
}
|
|
81
|
+
resolve(v);
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimeout(() => done('timeout'), 1500);
|
|
84
|
+
ws.on('message', (data) => {
|
|
85
|
+
const msg = JSON.parse(String(data)) as { type: string };
|
|
86
|
+
if (msg.type === 'messages') {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
done('messages');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
ws.on('close', (code) => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
done(`closed:${code}`);
|
|
94
|
+
});
|
|
95
|
+
ws.on('error', () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
done('closed:error');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('WsTransport loopback auth (GUI-002 TC-03)', () => {
|
|
103
|
+
it('attributes submissions with the trusted server-side surface driver id', async () => {
|
|
104
|
+
const port = 17650;
|
|
105
|
+
const submit = vi.fn().mockResolvedValue(undefined);
|
|
106
|
+
const session = Object.assign(mockSession(), { submit });
|
|
107
|
+
const transport = new WsTransport({
|
|
108
|
+
port,
|
|
109
|
+
maxRetries: 30,
|
|
110
|
+
open: true,
|
|
111
|
+
openReason: 'surface-attribution regression test',
|
|
112
|
+
driverId: 'app',
|
|
113
|
+
surface: 'desktop-app',
|
|
114
|
+
});
|
|
115
|
+
transport.attach(session);
|
|
116
|
+
await transport.start();
|
|
117
|
+
started.push(transport);
|
|
118
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
119
|
+
await new Promise<void>((resolve, reject) => {
|
|
120
|
+
ws.on('open', resolve);
|
|
121
|
+
ws.on('error', reject);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
ws.send(JSON.stringify({ type: 'submit', prompt: 'attribute me' }));
|
|
125
|
+
await vi.waitFor(() => expect(submit).toHaveBeenCalled());
|
|
126
|
+
|
|
127
|
+
expect(submit).toHaveBeenCalledWith('attribute me', undefined, undefined, {
|
|
128
|
+
driverId: 'app',
|
|
129
|
+
surface: 'desktop-app',
|
|
130
|
+
});
|
|
131
|
+
const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
|
|
132
|
+
ws.close();
|
|
133
|
+
await closed;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('accepts a connection presenting the correct token via query param', async () => {
|
|
137
|
+
const { port } = await startOn({ token: 'secret-nonce' });
|
|
138
|
+
expect(await probe(port, { token: 'secret-nonce' })).toBe('messages');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('accepts the correct token via Sec-WebSocket-Protocol subprotocol', async () => {
|
|
142
|
+
const { port } = await startOn({ token: 'secret-nonce' });
|
|
143
|
+
expect(await probe(port, { subprotocol: 'secret-nonce' })).toBe('messages');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rejects a MISSING token before any session data is emitted', async () => {
|
|
147
|
+
const { port } = await startOn({ token: 'secret-nonce' });
|
|
148
|
+
expect(await probe(port)).toBe('closed:1008'); // never 'messages'
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('rejects a WRONG token before any session data is emitted', async () => {
|
|
152
|
+
const { port } = await startOn({ token: 'secret-nonce' });
|
|
153
|
+
expect(await probe(port, { token: 'not-the-nonce' })).toBe('closed:1008');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── SEC-001 ────────────────────────────────────────────────────────────────
|
|
157
|
+
it('TC-01: NO token configured AUTO-MINTS one — unauthenticated rejected, minted token works', async () => {
|
|
158
|
+
const { port, token } = await startOn({}); // secure by default
|
|
159
|
+
expect(token).toBeTruthy();
|
|
160
|
+
expect(await probe(port)).toBe('closed:1008');
|
|
161
|
+
expect(await probe(port, { token })).toBe('messages');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('TC-05: the discouraged `open` opt-out restores unauthenticated behavior', async () => {
|
|
165
|
+
const { port, token } = await startOn({
|
|
166
|
+
open: true,
|
|
167
|
+
openReason: 'SEC-008: the discouraged opt-out is the SUBJECT of this case',
|
|
168
|
+
});
|
|
169
|
+
expect(token).toBeUndefined();
|
|
170
|
+
expect(await probe(port)).toBe('messages');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('TC-02: rejects a non-loopback Host at the upgrade (DNS-rebinding defense)', async () => {
|
|
174
|
+
const { port, token } = await startOn({ token: 'secret-nonce' });
|
|
175
|
+
expect(await probe(port, { token, headers: { host: 'evil.example.com' } })).not.toBe(
|
|
176
|
+
'messages',
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('TC-02: accepts a loopback Host on a port-agnostic match', async () => {
|
|
181
|
+
const { port, token } = await startOn({ token: 'secret-nonce' });
|
|
182
|
+
expect(await probe(port, { token, headers: { host: `localhost:${port}` } })).toBe('messages');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('TC-03: rejects a non-allowed browser Origin at the upgrade', async () => {
|
|
186
|
+
const { port, token } = await startOn({ token: 'secret-nonce' });
|
|
187
|
+
expect(await probe(port, { token, headers: { origin: 'https://evil.example.com' } })).not.toBe(
|
|
188
|
+
'messages',
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('TC-03: accepts a configured app Origin', async () => {
|
|
193
|
+
const { port, token } = await startOn({
|
|
194
|
+
token: 'secret-nonce',
|
|
195
|
+
allowedOrigins: ['https://app.example.com'],
|
|
196
|
+
});
|
|
197
|
+
expect(await probe(port, { token, headers: { origin: 'https://app.example.com' } })).toBe(
|
|
198
|
+
'messages',
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('SEC-008: `open` takes a written reason here too', () => {
|
|
204
|
+
// The first version filled a reason in for any caller that omitted one, which made WS the ONE
|
|
205
|
+
// transport where `{ open: true }` alone was accepted — HTTP and WebRTC both refuse it. Review
|
|
206
|
+
// found that: a reason invented on the caller's behalf reads, to the next person, as a decision
|
|
207
|
+
// somebody made, and the whole point of requiring it is that nobody can produce one by accident.
|
|
208
|
+
it('refuses an opt-out with no reason', () => {
|
|
209
|
+
expect(() => new WsTransport({ port: 17999, open: true })).toThrow(/openReason/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('accepts one that says why', () => {
|
|
213
|
+
// Without this the fix would be "refuse every open transport", which is not the decision — an
|
|
214
|
+
// open transport is legitimate, it just has to be chosen out loud.
|
|
215
|
+
expect(
|
|
216
|
+
() =>
|
|
217
|
+
new WsTransport({ port: 17999, open: true, openReason: 'loopback only — no remote peer' }),
|
|
218
|
+
).not.toThrow();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
|
|
3
|
+
import { runTransportLifecycleConformance } from '@robota-sdk/agent-interface-transport/testing';
|
|
4
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
5
|
+
|
|
6
|
+
import { WebSocket } from 'ws';
|
|
7
|
+
import { describe, it, expect, expectTypeOf, vi, afterEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { WsTransport } from '../ws-transport-configurable.js';
|
|
10
|
+
|
|
11
|
+
import type { IConfigurableTransport } from '@robota-sdk/agent-interface-transport';
|
|
12
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
13
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ARCH-004 RUNTIME-13 — `stop()` must resolve even with a client still connected.
|
|
17
|
+
*
|
|
18
|
+
* Before the fix, `stop()` did `wss.close(cb)`, whose callback fires only after every client socket is gone,
|
|
19
|
+
* so a live client made `stop()` hang forever. The fix sends a close frame to each client and terminates any
|
|
20
|
+
* survivor at a 5s deadline, so the server-close callback always resolves.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function mockSession(): IInteractiveSession {
|
|
24
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
25
|
+
getMessages: vi.fn().mockReturnValue([]),
|
|
26
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
27
|
+
on: vi.fn(),
|
|
28
|
+
off: vi.fn(),
|
|
29
|
+
submit: vi.fn(),
|
|
30
|
+
abort: vi.fn(),
|
|
31
|
+
cancelQueue: vi.fn(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const started: WsTransport[] = [];
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
while (started.length) await started.pop()!.stop();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('WsTransport lifecycle (ARCH-004 RUNTIME-13)', () => {
|
|
41
|
+
it('accepts only the protocol session roles required by the carrier', () => {
|
|
42
|
+
const transport = new WsTransport({
|
|
43
|
+
open: true,
|
|
44
|
+
openReason: 'type compatibility test',
|
|
45
|
+
});
|
|
46
|
+
expectTypeOf(transport).toMatchTypeOf<IConfigurableTransport<IProtocolSession>>();
|
|
47
|
+
expectTypeOf(transport.attach).toEqualTypeOf<(session: IProtocolSession) => void>();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('stop() resolves promptly with a client still connected (previously hung forever)', async () => {
|
|
51
|
+
const t = new WsTransport({
|
|
52
|
+
port: 17800,
|
|
53
|
+
maxRetries: 40,
|
|
54
|
+
open: true,
|
|
55
|
+
openReason: 'SEC-008: this case is about the stop() lifecycle, not admission',
|
|
56
|
+
});
|
|
57
|
+
t.attach(mockSession());
|
|
58
|
+
await t.start();
|
|
59
|
+
started.push(t);
|
|
60
|
+
const port = t.boundPort;
|
|
61
|
+
|
|
62
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
ws.on('open', () => resolve());
|
|
65
|
+
ws.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const startedAt = Date.now();
|
|
69
|
+
await t.stop(); // must not hang — the whole test would time out if it did
|
|
70
|
+
const elapsed = Date.now() - startedAt;
|
|
71
|
+
|
|
72
|
+
// A well-behaved client closes on the 1001 frame, so stop() resolves well under the 5s terminate deadline.
|
|
73
|
+
expect(elapsed).toBeLessThan(4500);
|
|
74
|
+
started.pop(); // already stopped
|
|
75
|
+
try {
|
|
76
|
+
ws.terminate();
|
|
77
|
+
} catch {
|
|
78
|
+
/* already closed */
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('can retry start after a bind failure without an intervening stop', async () => {
|
|
83
|
+
const blocker = createServer();
|
|
84
|
+
await new Promise<void>((resolve, reject) => {
|
|
85
|
+
blocker.once('error', reject);
|
|
86
|
+
blocker.listen(0, '127.0.0.1', resolve);
|
|
87
|
+
});
|
|
88
|
+
const address = blocker.address();
|
|
89
|
+
if (!address || typeof address === 'string') throw new Error('Expected a TCP address.');
|
|
90
|
+
|
|
91
|
+
const transport = new WsTransport({
|
|
92
|
+
port: address.port,
|
|
93
|
+
maxRetries: 0,
|
|
94
|
+
open: true,
|
|
95
|
+
openReason: 'bind failure lifecycle regression',
|
|
96
|
+
});
|
|
97
|
+
transport.attach(mockSession());
|
|
98
|
+
let blockerOpen = true;
|
|
99
|
+
try {
|
|
100
|
+
await expect(transport.start()).rejects.toMatchObject({ code: 'EADDRINUSE' });
|
|
101
|
+
await new Promise<void>((resolve, reject) =>
|
|
102
|
+
blocker.close((error) => (error ? reject(error) : resolve())),
|
|
103
|
+
);
|
|
104
|
+
blockerOpen = false;
|
|
105
|
+
|
|
106
|
+
await expect(transport.start()).resolves.toBeUndefined();
|
|
107
|
+
started.push(transport);
|
|
108
|
+
expect(transport.boundPort).toBe(address.port);
|
|
109
|
+
} finally {
|
|
110
|
+
if (blockerOpen) {
|
|
111
|
+
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('invokes the shared lifecycle conformance suite', async () => {
|
|
117
|
+
await runTransportLifecycleConformance({
|
|
118
|
+
subjectId: '@robota-sdk/agent-transport-ws#WsTransport',
|
|
119
|
+
kind: 'service',
|
|
120
|
+
createAdapter: () =>
|
|
121
|
+
new WsTransport({
|
|
122
|
+
port: 0,
|
|
123
|
+
open: true,
|
|
124
|
+
openReason: 'ARCH-011 lifecycle conformance',
|
|
125
|
+
}),
|
|
126
|
+
createSession: mockSession,
|
|
127
|
+
assertReady: (transport) => {
|
|
128
|
+
if (transport.boundPort === undefined) throw new Error('WS endpoint not bound');
|
|
129
|
+
},
|
|
130
|
+
assertStopped: (transport) => {
|
|
131
|
+
if (transport.boundPort !== undefined) throw new Error('WS endpoint still bound');
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runTransportLifecycleConformance } from '@robota-sdk/agent-interface-transport/testing';
|
|
2
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, expectTypeOf, vi } from 'vitest';
|
|
2
5
|
import { createWsTransport } from '../ws-transport.js';
|
|
3
|
-
import type {
|
|
6
|
+
import type { ITransportAdapter } from '@robota-sdk/agent-interface-transport';
|
|
7
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
8
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
4
9
|
|
|
5
10
|
function createMockSession(): IInteractiveSession {
|
|
6
|
-
return {
|
|
11
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
7
12
|
submit: vi.fn(),
|
|
8
13
|
abort: vi.fn(),
|
|
9
14
|
cancelQueue: vi.fn(),
|
|
@@ -17,10 +22,16 @@ function createMockSession(): IInteractiveSession {
|
|
|
17
22
|
listCommands: vi.fn().mockReturnValue([]),
|
|
18
23
|
on: vi.fn(),
|
|
19
24
|
off: vi.fn(),
|
|
20
|
-
}
|
|
25
|
+
});
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
describe('createWsTransport', () => {
|
|
29
|
+
it('accepts only the protocol session roles required by the carrier', () => {
|
|
30
|
+
const transport = createWsTransport({ send: vi.fn() });
|
|
31
|
+
expectTypeOf(transport).toMatchTypeOf<ITransportAdapter<IProtocolSession>>();
|
|
32
|
+
expectTypeOf(transport.attach).toEqualTypeOf<(session: IProtocolSession) => void>();
|
|
33
|
+
});
|
|
34
|
+
|
|
24
35
|
it('returns an adapter with name "ws"', () => {
|
|
25
36
|
const transport = createWsTransport({ send: vi.fn() });
|
|
26
37
|
expect(transport.name).toBe('ws');
|
|
@@ -28,7 +39,10 @@ describe('createWsTransport', () => {
|
|
|
28
39
|
|
|
29
40
|
it('throws if start() is called without attach()', async () => {
|
|
30
41
|
const transport = createWsTransport({ send: vi.fn() });
|
|
31
|
-
await expect(transport.start()).rejects.
|
|
42
|
+
await expect(transport.start()).rejects.toMatchObject({
|
|
43
|
+
name: 'TransportLifecycleError',
|
|
44
|
+
code: 'not-attached',
|
|
45
|
+
});
|
|
32
46
|
});
|
|
33
47
|
|
|
34
48
|
it('onMessage is null before start()', () => {
|
|
@@ -50,4 +64,54 @@ describe('createWsTransport', () => {
|
|
|
50
64
|
await transport.stop();
|
|
51
65
|
expect(transport.onMessage).toBeNull();
|
|
52
66
|
});
|
|
67
|
+
|
|
68
|
+
it('isolates an outbound failure, cleans subscriptions, and reports to the carrier owner', async () => {
|
|
69
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
70
|
+
const session = createTestInteractiveSession({
|
|
71
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
72
|
+
handlers.set(event, handler);
|
|
73
|
+
}) as IInteractiveSession['on'],
|
|
74
|
+
off: ((event: string) => {
|
|
75
|
+
handlers.delete(event);
|
|
76
|
+
}) as IInteractiveSession['off'],
|
|
77
|
+
});
|
|
78
|
+
const onDeliveryError = vi.fn();
|
|
79
|
+
const transport = createWsTransport({
|
|
80
|
+
send: () => {
|
|
81
|
+
throw new Error('functional adapter socket closed');
|
|
82
|
+
},
|
|
83
|
+
onDeliveryError,
|
|
84
|
+
});
|
|
85
|
+
transport.attach(session);
|
|
86
|
+
await transport.start();
|
|
87
|
+
|
|
88
|
+
expect(() =>
|
|
89
|
+
handlers.get('branch_event')?.({
|
|
90
|
+
kind: 'checkpoint_created',
|
|
91
|
+
checkpointId: 'turn-0001',
|
|
92
|
+
branchId: 'main',
|
|
93
|
+
}),
|
|
94
|
+
).not.toThrow();
|
|
95
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
96
|
+
expect.objectContaining({ message: 'functional adapter socket closed' }),
|
|
97
|
+
'branch_event',
|
|
98
|
+
);
|
|
99
|
+
expect(handlers.size).toBe(0);
|
|
100
|
+
expect(transport.onMessage).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('invokes the shared lifecycle conformance suite', async () => {
|
|
104
|
+
await runTransportLifecycleConformance({
|
|
105
|
+
subjectId: '@robota-sdk/agent-transport-ws#createWsTransport',
|
|
106
|
+
kind: 'service',
|
|
107
|
+
createAdapter: () => createWsTransport({ send: vi.fn() }),
|
|
108
|
+
createSession: createMockSession,
|
|
109
|
+
assertReady: (transport) => {
|
|
110
|
+
if (typeof transport.onMessage !== 'function') throw new Error('WS handler not ready');
|
|
111
|
+
},
|
|
112
|
+
assertStopped: (transport) => {
|
|
113
|
+
if (transport.onMessage !== null) throw new Error('WS handler still ready');
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
});
|
|
53
117
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export { createWsHandler } from './ws-handler.js';
|
|
2
|
-
export type { IWsHandlerOptions } from './ws-handler.js';
|
|
3
|
-
export type { TClientMessage, TServerMessage } from './ws-protocol.js';
|
|
4
1
|
export { createWsTransport } from './ws-transport.js';
|
|
5
|
-
export type { IWsTransportOptions } from './ws-transport.js';
|
|
2
|
+
export type { IWsTransport, IWsTransportOptions } from './ws-transport.js';
|
|
6
3
|
export { WsTransport } from './ws-transport-configurable.js';
|
|
7
|
-
export type { IWsTransportConfig } from './ws-transport-
|
|
4
|
+
export type { IWsTransportConfig } from './ws-transport-config.js';
|
|
5
|
+
// TRANS-001 — payload-agnostic channel multiplexing (also usable standalone by another carrier).
|
|
6
|
+
export { PayloadChannelRegistry } from './payload-channels.js';
|
|
7
|
+
export type { TChannelSink } from './payload-channels.js';
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload-agnostic channel registry (TRANS-001) — the transport-side half of
|
|
3
|
+
* {@link IPayloadChannelHost}.
|
|
4
|
+
*
|
|
5
|
+
* Owns the multiplexing the carrier needs and nothing else: which channels exist, what each one
|
|
6
|
+
* declared, per-channel outbound `seq`, the attached connection sinks, and inbound routing. It is
|
|
7
|
+
* completely blind to payload semantics — a channel carrying audio, a file, or app events is the
|
|
8
|
+
* same object to this class.
|
|
9
|
+
*
|
|
10
|
+
* Every rejection is EXPLICIT: an undeclared event, binary traffic on a text-only channel, or a
|
|
11
|
+
* frame for an unregistered channel comes back as a stated error the carrier reports to the peer.
|
|
12
|
+
* Nothing is silently dropped or defaulted.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
decodeChannelFrame,
|
|
17
|
+
encodeBinaryFrame,
|
|
18
|
+
encodeChannelEventFrame,
|
|
19
|
+
} from '@robota-sdk/agent-transport';
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
IBinaryFrame,
|
|
23
|
+
IChannelDescriptor,
|
|
24
|
+
IChannelEventFrame,
|
|
25
|
+
IPayloadChannel,
|
|
26
|
+
IPayloadChannelHost,
|
|
27
|
+
TChannelEventMap,
|
|
28
|
+
TChannelReceiveResult,
|
|
29
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
30
|
+
|
|
31
|
+
/** Delivers an encoded frame to one attached connection. */
|
|
32
|
+
export type TChannelSink = (frame: Uint8Array) => void;
|
|
33
|
+
|
|
34
|
+
type TBinaryHandler = (frame: IBinaryFrame) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Storage type for a consumer's event handler. `never` parameters make every concrete
|
|
37
|
+
* `(payload: TEvents[K], frame: IChannelEventFrame<TEvents[K]>) => void` assignable to it, so the
|
|
38
|
+
* per-channel handler sets stay heterogeneous without an `unknown` double-cast. The declared-event
|
|
39
|
+
* check at both `onEvent` and `receive` is what keeps the erased types honest at runtime.
|
|
40
|
+
*/
|
|
41
|
+
type TEventHandler = (payload: never, frame: never) => void;
|
|
42
|
+
|
|
43
|
+
/** Per-channel mutable state. Kept private to the registry — consumers only see `IPayloadChannel`. */
|
|
44
|
+
interface IChannelState {
|
|
45
|
+
readonly descriptor: IChannelDescriptor;
|
|
46
|
+
readonly declaredEvents: ReadonlySet<string>;
|
|
47
|
+
readonly eventHandlers: Map<string, Set<TEventHandler>>;
|
|
48
|
+
readonly binaryHandlers: Set<TBinaryHandler>;
|
|
49
|
+
seq: number;
|
|
50
|
+
open: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class PayloadChannelRegistry implements IPayloadChannelHost {
|
|
54
|
+
private readonly channels = new Map<string, IChannelState>();
|
|
55
|
+
private readonly sinks = new Set<TChannelSink>();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Attach a connection. Every frame a channel sends is fanned out to all attached sinks; the
|
|
59
|
+
* returned function detaches (call it on socket close).
|
|
60
|
+
*/
|
|
61
|
+
addSink(sink: TChannelSink): () => void {
|
|
62
|
+
this.sinks.add(sink);
|
|
63
|
+
return (): void => {
|
|
64
|
+
this.sinks.delete(sink);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
registerChannel<TEvents extends TChannelEventMap>(
|
|
69
|
+
descriptor: IChannelDescriptor<TEvents>,
|
|
70
|
+
): IPayloadChannel<TEvents> {
|
|
71
|
+
if (this.channels.has(descriptor.name)) {
|
|
72
|
+
throw new Error(`Channel "${descriptor.name}" is already registered on this transport`);
|
|
73
|
+
}
|
|
74
|
+
const state: IChannelState = {
|
|
75
|
+
descriptor: descriptor as IChannelDescriptor,
|
|
76
|
+
declaredEvents: new Set<string>(descriptor.events),
|
|
77
|
+
eventHandlers: new Map(),
|
|
78
|
+
binaryHandlers: new Set(),
|
|
79
|
+
seq: 0,
|
|
80
|
+
open: true,
|
|
81
|
+
};
|
|
82
|
+
this.channels.set(descriptor.name, state);
|
|
83
|
+
return this.createHandle<TEvents>(descriptor, state);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Route one inbound encoded frame to its channel's handlers. The result states exactly what
|
|
88
|
+
* happened so the carrier can answer the peer (a `protocol_error`) instead of dropping it.
|
|
89
|
+
*/
|
|
90
|
+
receive(bytes: Uint8Array): TChannelReceiveResult {
|
|
91
|
+
const decoded = decodeChannelFrame(bytes);
|
|
92
|
+
if (!decoded.ok) return decoded;
|
|
93
|
+
|
|
94
|
+
const { frame } = decoded;
|
|
95
|
+
const state = this.channels.get(frame.channel);
|
|
96
|
+
if (!state || !state.open) {
|
|
97
|
+
return { ok: false, error: `No channel registered for "${frame.channel}"` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (frame.kind === 'binary') {
|
|
101
|
+
if (state.descriptor.binary !== true) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: `Channel "${frame.channel}" did not declare binary payload support`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
for (const handler of [...state.binaryHandlers]) handler(frame);
|
|
108
|
+
return { ok: true, frame };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!state.declaredEvents.has(frame.event)) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: `Channel "${frame.channel}" did not declare event "${frame.event}"`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const handlers = state.eventHandlers.get(frame.event);
|
|
118
|
+
if (handlers) {
|
|
119
|
+
for (const handler of [...handlers]) handler(frame.payload as never, frame as never);
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, frame };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Fan one encoded frame out to every attached connection. */
|
|
125
|
+
private broadcast(frame: Uint8Array): void {
|
|
126
|
+
for (const sink of [...this.sinks]) sink(frame);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Assert the channel is still open before any send/subscribe. */
|
|
130
|
+
private static assertOpen(state: IChannelState): void {
|
|
131
|
+
if (!state.open) throw new Error(`Channel "${state.descriptor.name}" is closed`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private createHandle<TEvents extends TChannelEventMap>(
|
|
135
|
+
descriptor: IChannelDescriptor<TEvents>,
|
|
136
|
+
state: IChannelState,
|
|
137
|
+
): IPayloadChannel<TEvents> {
|
|
138
|
+
return {
|
|
139
|
+
descriptor,
|
|
140
|
+
|
|
141
|
+
sendEvent: <K extends keyof TEvents & string>(event: K, payload: TEvents[K]): void => {
|
|
142
|
+
PayloadChannelRegistry.assertOpen(state);
|
|
143
|
+
if (!state.declaredEvents.has(event)) {
|
|
144
|
+
throw new Error(`Channel "${descriptor.name}" did not declare event "${event}"`);
|
|
145
|
+
}
|
|
146
|
+
const seq = state.seq;
|
|
147
|
+
state.seq += 1;
|
|
148
|
+
this.broadcast(
|
|
149
|
+
encodeChannelEventFrame({
|
|
150
|
+
kind: 'event',
|
|
151
|
+
channel: descriptor.name,
|
|
152
|
+
seq,
|
|
153
|
+
event,
|
|
154
|
+
payload,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
sendBinary: (payload: Uint8Array): void => {
|
|
160
|
+
PayloadChannelRegistry.assertOpen(state);
|
|
161
|
+
if (descriptor.binary !== true) {
|
|
162
|
+
throw new Error(`Channel "${descriptor.name}" did not declare binary payload support`);
|
|
163
|
+
}
|
|
164
|
+
const seq = state.seq;
|
|
165
|
+
state.seq += 1;
|
|
166
|
+
this.broadcast(
|
|
167
|
+
encodeBinaryFrame({ kind: 'binary', channel: descriptor.name, seq, payload }),
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
onEvent: <K extends keyof TEvents & string>(
|
|
172
|
+
event: K,
|
|
173
|
+
handler: (payload: TEvents[K], frame: IChannelEventFrame<TEvents[K]>) => void,
|
|
174
|
+
): (() => void) => {
|
|
175
|
+
PayloadChannelRegistry.assertOpen(state);
|
|
176
|
+
if (!state.declaredEvents.has(event)) {
|
|
177
|
+
throw new Error(`Channel "${descriptor.name}" did not declare event "${event}"`);
|
|
178
|
+
}
|
|
179
|
+
const set = state.eventHandlers.get(event) ?? new Set<TEventHandler>();
|
|
180
|
+
const typed: TEventHandler = handler;
|
|
181
|
+
set.add(typed);
|
|
182
|
+
state.eventHandlers.set(event, set);
|
|
183
|
+
return (): void => {
|
|
184
|
+
set.delete(typed);
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
onBinary: (handler: (frame: IBinaryFrame) => void): (() => void) => {
|
|
189
|
+
PayloadChannelRegistry.assertOpen(state);
|
|
190
|
+
state.binaryHandlers.add(handler);
|
|
191
|
+
return (): void => {
|
|
192
|
+
state.binaryHandlers.delete(handler);
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
close: (): void => {
|
|
197
|
+
state.open = false;
|
|
198
|
+
state.eventHandlers.clear();
|
|
199
|
+
state.binaryHandlers.clear();
|
|
200
|
+
this.channels.delete(descriptor.name);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|