@byoky/relay 0.4.2 → 0.4.4
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/package.json +1 -1
- package/tests/server.test.ts +617 -0
package/package.json
CHANGED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
+
|
|
4
|
+
// We spin up the relay server in-process for testing
|
|
5
|
+
// by importing the server module's logic directly.
|
|
6
|
+
// Since server.ts auto-starts, we'll start our own WSS instead.
|
|
7
|
+
|
|
8
|
+
const TEST_PORT = 19876;
|
|
9
|
+
const WS_URL = `ws://127.0.0.1:${TEST_PORT}`;
|
|
10
|
+
|
|
11
|
+
// --- Minimal relay server reimplementation for testing ---
|
|
12
|
+
// (We can't import server.ts directly because it auto-binds on import)
|
|
13
|
+
|
|
14
|
+
interface Room {
|
|
15
|
+
sender?: WebSocket;
|
|
16
|
+
recipient?: WebSocket;
|
|
17
|
+
authToken: string;
|
|
18
|
+
lastActivity: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let wss: WebSocketServer;
|
|
22
|
+
let rooms: Map<string, Room>;
|
|
23
|
+
|
|
24
|
+
function send(ws: WebSocket, data: unknown): void {
|
|
25
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
26
|
+
ws.send(JSON.stringify(data));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function startServer(): Promise<void> {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
rooms = new Map();
|
|
33
|
+
wss = new WebSocketServer({ port: TEST_PORT }, () => resolve());
|
|
34
|
+
|
|
35
|
+
wss.on('connection', (ws) => {
|
|
36
|
+
let authedGiftId: string | null = null;
|
|
37
|
+
let authedRole: 'sender' | 'recipient' | null = null;
|
|
38
|
+
|
|
39
|
+
ws.on('message', (raw) => {
|
|
40
|
+
let msg: any;
|
|
41
|
+
try { msg = JSON.parse(String(raw)); } catch { return; }
|
|
42
|
+
|
|
43
|
+
if (!authedGiftId) {
|
|
44
|
+
if (msg.type !== 'gift:auth') return;
|
|
45
|
+
const { giftId, authToken, role } = msg;
|
|
46
|
+
if (typeof giftId !== 'string' || typeof authToken !== 'string' ||
|
|
47
|
+
(role !== 'sender' && role !== 'recipient')) {
|
|
48
|
+
send(ws, { type: 'gift:auth:result', success: false, error: 'invalid auth payload' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let room = rooms.get(giftId);
|
|
53
|
+
if (room) {
|
|
54
|
+
if (room.authToken !== authToken) {
|
|
55
|
+
send(ws, { type: 'gift:auth:result', success: false, error: 'auth token mismatch' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (room[role] && room[role]!.readyState === WebSocket.OPEN) {
|
|
59
|
+
send(ws, { type: 'gift:auth:result', success: false, error: `${role} already connected` });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
room = { authToken, lastActivity: Date.now() };
|
|
64
|
+
rooms.set(giftId, room);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
room[role] = ws;
|
|
68
|
+
room.lastActivity = Date.now();
|
|
69
|
+
authedGiftId = giftId;
|
|
70
|
+
authedRole = role;
|
|
71
|
+
|
|
72
|
+
const peer = role === 'sender' ? room.recipient : room.sender;
|
|
73
|
+
const peerOnline = !!peer && peer.readyState === WebSocket.OPEN;
|
|
74
|
+
send(ws, { type: 'gift:auth:result', success: true, peerOnline });
|
|
75
|
+
|
|
76
|
+
if (peerOnline) {
|
|
77
|
+
send(peer!, { type: 'gift:peer:status', online: true });
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const room = rooms.get(authedGiftId);
|
|
83
|
+
if (!room) return;
|
|
84
|
+
room.lastActivity = Date.now();
|
|
85
|
+
|
|
86
|
+
if (authedRole === 'recipient' && msg.type === 'relay:request') {
|
|
87
|
+
if (room.sender?.readyState === WebSocket.OPEN) {
|
|
88
|
+
room.sender.send(String(raw));
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (authedRole === 'sender') {
|
|
94
|
+
if (['relay:response:meta', 'relay:response:chunk',
|
|
95
|
+
'relay:response:done', 'relay:response:error',
|
|
96
|
+
'gift:usage'].includes(msg.type)) {
|
|
97
|
+
if (room.recipient?.readyState === WebSocket.OPEN) {
|
|
98
|
+
room.recipient.send(String(raw));
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ws.on('close', () => {
|
|
106
|
+
if (!authedGiftId || !authedRole) return;
|
|
107
|
+
const room = rooms.get(authedGiftId);
|
|
108
|
+
if (!room) return;
|
|
109
|
+
room[authedRole] = undefined;
|
|
110
|
+
const peer = authedRole === 'sender' ? room.recipient : room.sender;
|
|
111
|
+
if (peer?.readyState === WebSocket.OPEN) {
|
|
112
|
+
send(peer, { type: 'gift:peer:status', online: false });
|
|
113
|
+
}
|
|
114
|
+
if (!room.sender && !room.recipient) {
|
|
115
|
+
rooms.delete(authedGiftId);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stopServer(): Promise<void> {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
for (const client of wss.clients) {
|
|
125
|
+
client.terminate();
|
|
126
|
+
}
|
|
127
|
+
wss.close(() => resolve());
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Helpers ---
|
|
132
|
+
|
|
133
|
+
function connect(): Promise<WebSocket> {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const ws = new WebSocket(WS_URL);
|
|
136
|
+
ws.on('open', () => resolve(ws));
|
|
137
|
+
ws.on('error', reject);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function waitForMessage(ws: WebSocket, timeoutMs = 2000): Promise<any> {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const timer = setTimeout(() => reject(new Error('Timeout waiting for message')), timeoutMs);
|
|
144
|
+
ws.once('message', (data) => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
resolve(JSON.parse(String(data)));
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function authenticate(ws: WebSocket, giftId: string, authToken: string, role: 'sender' | 'recipient'): Promise<any> {
|
|
152
|
+
ws.send(JSON.stringify({ type: 'gift:auth', giftId, authToken, role }));
|
|
153
|
+
return waitForMessage(ws);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function waitForClose(ws: WebSocket, timeoutMs = 2000): Promise<void> {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
if (ws.readyState === WebSocket.CLOSED) return resolve();
|
|
159
|
+
const timer = setTimeout(() => reject(new Error('Timeout waiting for close')), timeoutMs);
|
|
160
|
+
ws.on('close', () => { clearTimeout(timer); resolve(); });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Tests ---
|
|
165
|
+
|
|
166
|
+
beforeAll(async () => {
|
|
167
|
+
await startServer();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterAll(async () => {
|
|
171
|
+
await stopServer();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
// Clean up rooms between tests
|
|
176
|
+
rooms.clear();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('Authentication', () => {
|
|
180
|
+
it('sender can authenticate and create a room', async () => {
|
|
181
|
+
const ws = await connect();
|
|
182
|
+
const result = await authenticate(ws, 'gift_1', 'token_abc', 'sender');
|
|
183
|
+
expect(result).toEqual({ type: 'gift:auth:result', success: true, peerOnline: false });
|
|
184
|
+
ws.close();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('recipient can authenticate and create a room', async () => {
|
|
188
|
+
const ws = await connect();
|
|
189
|
+
const result = await authenticate(ws, 'gift_2', 'token_def', 'recipient');
|
|
190
|
+
expect(result).toEqual({ type: 'gift:auth:result', success: true, peerOnline: false });
|
|
191
|
+
ws.close();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('rejects invalid auth payload (missing role)', async () => {
|
|
195
|
+
const ws = await connect();
|
|
196
|
+
ws.send(JSON.stringify({ type: 'gift:auth', giftId: 'gift_3', authToken: 'tok' }));
|
|
197
|
+
const result = await waitForMessage(ws);
|
|
198
|
+
expect(result.success).toBe(false);
|
|
199
|
+
expect(result.error).toContain('invalid auth payload');
|
|
200
|
+
ws.close();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('rejects auth token mismatch', async () => {
|
|
204
|
+
const sender = await connect();
|
|
205
|
+
await authenticate(sender, 'gift_4', 'correct_token', 'sender');
|
|
206
|
+
|
|
207
|
+
const recipient = await connect();
|
|
208
|
+
const result = await authenticate(recipient, 'gift_4', 'wrong_token', 'recipient');
|
|
209
|
+
expect(result.success).toBe(false);
|
|
210
|
+
expect(result.error).toContain('auth token mismatch');
|
|
211
|
+
|
|
212
|
+
sender.close();
|
|
213
|
+
recipient.close();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('rejects duplicate sender for same gift', async () => {
|
|
217
|
+
const sender1 = await connect();
|
|
218
|
+
await authenticate(sender1, 'gift_5', 'token_5', 'sender');
|
|
219
|
+
|
|
220
|
+
const sender2 = await connect();
|
|
221
|
+
const result = await authenticate(sender2, 'gift_5', 'token_5', 'sender');
|
|
222
|
+
expect(result.success).toBe(false);
|
|
223
|
+
expect(result.error).toContain('sender already connected');
|
|
224
|
+
|
|
225
|
+
sender1.close();
|
|
226
|
+
sender2.close();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('rejects duplicate recipient for same gift', async () => {
|
|
230
|
+
const recipient1 = await connect();
|
|
231
|
+
await authenticate(recipient1, 'gift_6', 'token_6', 'recipient');
|
|
232
|
+
|
|
233
|
+
const recipient2 = await connect();
|
|
234
|
+
const result = await authenticate(recipient2, 'gift_6', 'token_6', 'recipient');
|
|
235
|
+
expect(result.success).toBe(false);
|
|
236
|
+
expect(result.error).toContain('recipient already connected');
|
|
237
|
+
|
|
238
|
+
recipient1.close();
|
|
239
|
+
recipient2.close();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('ignores non-auth messages before authentication', async () => {
|
|
243
|
+
const ws = await connect();
|
|
244
|
+
ws.send(JSON.stringify({ type: 'relay:request', data: 'hello' }));
|
|
245
|
+
// Should not receive any response — wait briefly and verify
|
|
246
|
+
const gotMessage = await new Promise<boolean>((resolve) => {
|
|
247
|
+
const timer = setTimeout(() => resolve(false), 500);
|
|
248
|
+
ws.once('message', () => { clearTimeout(timer); resolve(true); });
|
|
249
|
+
});
|
|
250
|
+
expect(gotMessage).toBe(false);
|
|
251
|
+
ws.close();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('Peer Status', () => {
|
|
256
|
+
it('sender sees peerOnline:true when recipient is already connected', async () => {
|
|
257
|
+
const recipient = await connect();
|
|
258
|
+
await authenticate(recipient, 'gift_10', 'token_10', 'recipient');
|
|
259
|
+
|
|
260
|
+
const sender = await connect();
|
|
261
|
+
const senderResult = await authenticate(sender, 'gift_10', 'token_10', 'sender');
|
|
262
|
+
expect(senderResult.peerOnline).toBe(true);
|
|
263
|
+
|
|
264
|
+
// Recipient should also get peer:status online
|
|
265
|
+
const peerStatus = await waitForMessage(recipient);
|
|
266
|
+
expect(peerStatus).toEqual({ type: 'gift:peer:status', online: true });
|
|
267
|
+
|
|
268
|
+
sender.close();
|
|
269
|
+
recipient.close();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('recipient gets peer:status offline when sender disconnects', async () => {
|
|
273
|
+
// Recipient connects first this time
|
|
274
|
+
const recipient = await connect();
|
|
275
|
+
await authenticate(recipient, 'gift_11', 'token_11', 'recipient');
|
|
276
|
+
|
|
277
|
+
const sender = await connect();
|
|
278
|
+
const senderAuth = await authenticate(sender, 'gift_11', 'token_11', 'sender');
|
|
279
|
+
expect(senderAuth.peerOnline).toBe(true);
|
|
280
|
+
|
|
281
|
+
// Recipient gets peer:status online notification
|
|
282
|
+
const online = await waitForMessage(recipient);
|
|
283
|
+
expect(online).toEqual({ type: 'gift:peer:status', online: true });
|
|
284
|
+
|
|
285
|
+
sender.close();
|
|
286
|
+
const offline = await waitForMessage(recipient);
|
|
287
|
+
expect(offline).toEqual({ type: 'gift:peer:status', online: false });
|
|
288
|
+
|
|
289
|
+
recipient.close();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('sender gets peer:status offline when recipient disconnects', async () => {
|
|
293
|
+
const sender = await connect();
|
|
294
|
+
await authenticate(sender, 'gift_12', 'token_12', 'sender');
|
|
295
|
+
|
|
296
|
+
const recipient = await connect();
|
|
297
|
+
await authenticate(recipient, 'gift_12', 'token_12', 'recipient');
|
|
298
|
+
// Consume the peer:status online message from sender side
|
|
299
|
+
await waitForMessage(sender);
|
|
300
|
+
|
|
301
|
+
recipient.close();
|
|
302
|
+
const offline = await waitForMessage(sender);
|
|
303
|
+
expect(offline).toEqual({ type: 'gift:peer:status', online: false });
|
|
304
|
+
|
|
305
|
+
sender.close();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('Request/Response Relay', () => {
|
|
310
|
+
it('relays request from recipient to sender', async () => {
|
|
311
|
+
const sender = await connect();
|
|
312
|
+
await authenticate(sender, 'gift_20', 'token_20', 'sender');
|
|
313
|
+
|
|
314
|
+
const recipient = await connect();
|
|
315
|
+
await authenticate(recipient, 'gift_20', 'token_20', 'recipient');
|
|
316
|
+
// Consume peer status messages
|
|
317
|
+
await waitForMessage(sender);
|
|
318
|
+
|
|
319
|
+
const request = {
|
|
320
|
+
type: 'relay:request',
|
|
321
|
+
id: 'req_1',
|
|
322
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
323
|
+
method: 'POST',
|
|
324
|
+
headers: { 'Content-Type': 'application/json' },
|
|
325
|
+
body: '{"model":"claude-sonnet-4-20250514"}',
|
|
326
|
+
};
|
|
327
|
+
recipient.send(JSON.stringify(request));
|
|
328
|
+
|
|
329
|
+
const received = await waitForMessage(sender);
|
|
330
|
+
expect(received.type).toBe('relay:request');
|
|
331
|
+
expect(received.id).toBe('req_1');
|
|
332
|
+
expect(received.url).toBe('https://api.anthropic.com/v1/messages');
|
|
333
|
+
|
|
334
|
+
sender.close();
|
|
335
|
+
recipient.close();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('relays response meta from sender to recipient', async () => {
|
|
339
|
+
const sender = await connect();
|
|
340
|
+
await authenticate(sender, 'gift_21', 'token_21', 'sender');
|
|
341
|
+
|
|
342
|
+
const recipient = await connect();
|
|
343
|
+
await authenticate(recipient, 'gift_21', 'token_21', 'recipient');
|
|
344
|
+
await waitForMessage(sender); // peer status
|
|
345
|
+
|
|
346
|
+
const meta = {
|
|
347
|
+
type: 'relay:response:meta',
|
|
348
|
+
id: 'req_1',
|
|
349
|
+
status: 200,
|
|
350
|
+
headers: { 'content-type': 'application/json' },
|
|
351
|
+
};
|
|
352
|
+
sender.send(JSON.stringify(meta));
|
|
353
|
+
|
|
354
|
+
const received = await waitForMessage(recipient);
|
|
355
|
+
expect(received.type).toBe('relay:response:meta');
|
|
356
|
+
expect(received.status).toBe(200);
|
|
357
|
+
|
|
358
|
+
sender.close();
|
|
359
|
+
recipient.close();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('relays response chunks from sender to recipient', async () => {
|
|
363
|
+
const sender = await connect();
|
|
364
|
+
await authenticate(sender, 'gift_22', 'token_22', 'sender');
|
|
365
|
+
|
|
366
|
+
const recipient = await connect();
|
|
367
|
+
await authenticate(recipient, 'gift_22', 'token_22', 'recipient');
|
|
368
|
+
await waitForMessage(sender); // peer online
|
|
369
|
+
|
|
370
|
+
// Collect all messages on recipient side
|
|
371
|
+
const messages: any[] = [];
|
|
372
|
+
const allReceived = new Promise<void>((resolve) => {
|
|
373
|
+
recipient.on('message', (data) => {
|
|
374
|
+
messages.push(JSON.parse(String(data)));
|
|
375
|
+
if (messages.length >= 3) resolve();
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
sender.send(JSON.stringify({ type: 'relay:response:chunk', id: 'req_1', data: 'Hello ' }));
|
|
380
|
+
sender.send(JSON.stringify({ type: 'relay:response:chunk', id: 'req_1', data: 'World' }));
|
|
381
|
+
sender.send(JSON.stringify({ type: 'relay:response:done', id: 'req_1' }));
|
|
382
|
+
|
|
383
|
+
await allReceived;
|
|
384
|
+
|
|
385
|
+
expect(messages[0].type).toBe('relay:response:chunk');
|
|
386
|
+
expect(messages[0].data).toBe('Hello ');
|
|
387
|
+
expect(messages[1].type).toBe('relay:response:chunk');
|
|
388
|
+
expect(messages[1].data).toBe('World');
|
|
389
|
+
expect(messages[2].type).toBe('relay:response:done');
|
|
390
|
+
|
|
391
|
+
sender.close();
|
|
392
|
+
recipient.close();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('relays response error from sender to recipient', async () => {
|
|
396
|
+
const sender = await connect();
|
|
397
|
+
await authenticate(sender, 'gift_23', 'token_23', 'sender');
|
|
398
|
+
|
|
399
|
+
const recipient = await connect();
|
|
400
|
+
await authenticate(recipient, 'gift_23', 'token_23', 'recipient');
|
|
401
|
+
await waitForMessage(sender);
|
|
402
|
+
|
|
403
|
+
const error = { type: 'relay:response:error', id: 'req_1', error: 'rate limited' };
|
|
404
|
+
sender.send(JSON.stringify(error));
|
|
405
|
+
|
|
406
|
+
const received = await waitForMessage(recipient);
|
|
407
|
+
expect(received.type).toBe('relay:response:error');
|
|
408
|
+
expect(received.error).toBe('rate limited');
|
|
409
|
+
|
|
410
|
+
sender.close();
|
|
411
|
+
recipient.close();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('relays usage updates from sender to recipient', async () => {
|
|
415
|
+
const sender = await connect();
|
|
416
|
+
await authenticate(sender, 'gift_24', 'token_24', 'sender');
|
|
417
|
+
|
|
418
|
+
const recipient = await connect();
|
|
419
|
+
await authenticate(recipient, 'gift_24', 'token_24', 'recipient');
|
|
420
|
+
await waitForMessage(sender);
|
|
421
|
+
|
|
422
|
+
const usage = { type: 'gift:usage', giftId: 'gift_24', usedTokens: 5000 };
|
|
423
|
+
sender.send(JSON.stringify(usage));
|
|
424
|
+
|
|
425
|
+
const received = await waitForMessage(recipient);
|
|
426
|
+
expect(received.type).toBe('gift:usage');
|
|
427
|
+
expect(received.usedTokens).toBe(5000);
|
|
428
|
+
|
|
429
|
+
sender.close();
|
|
430
|
+
recipient.close();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('does NOT relay recipient messages other than relay:request to sender', async () => {
|
|
434
|
+
const sender = await connect();
|
|
435
|
+
await authenticate(sender, 'gift_25', 'token_25', 'sender');
|
|
436
|
+
|
|
437
|
+
const recipient = await connect();
|
|
438
|
+
await authenticate(recipient, 'gift_25', 'token_25', 'recipient');
|
|
439
|
+
await waitForMessage(sender); // peer online
|
|
440
|
+
|
|
441
|
+
// Recipient tries to send a non-request message
|
|
442
|
+
recipient.send(JSON.stringify({ type: 'gift:usage', giftId: 'gift_25', usedTokens: 999 }));
|
|
443
|
+
|
|
444
|
+
const gotMessage = await new Promise<boolean>((resolve) => {
|
|
445
|
+
const timer = setTimeout(() => resolve(false), 500);
|
|
446
|
+
sender.once('message', () => { clearTimeout(timer); resolve(true); });
|
|
447
|
+
});
|
|
448
|
+
expect(gotMessage).toBe(false);
|
|
449
|
+
|
|
450
|
+
sender.close();
|
|
451
|
+
recipient.close();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('does NOT relay sender messages with unknown types to recipient', async () => {
|
|
455
|
+
const sender = await connect();
|
|
456
|
+
await authenticate(sender, 'gift_26', 'token_26', 'sender');
|
|
457
|
+
|
|
458
|
+
const recipient = await connect();
|
|
459
|
+
await authenticate(recipient, 'gift_26', 'token_26', 'recipient');
|
|
460
|
+
await waitForMessage(sender); // peer online
|
|
461
|
+
|
|
462
|
+
sender.send(JSON.stringify({ type: 'unknown:message', data: 'sneaky' }));
|
|
463
|
+
|
|
464
|
+
const gotMessage = await new Promise<boolean>((resolve) => {
|
|
465
|
+
const timer = setTimeout(() => resolve(false), 500);
|
|
466
|
+
recipient.once('message', () => { clearTimeout(timer); resolve(true); });
|
|
467
|
+
});
|
|
468
|
+
expect(gotMessage).toBe(false);
|
|
469
|
+
|
|
470
|
+
sender.close();
|
|
471
|
+
recipient.close();
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe('Room Cleanup', () => {
|
|
476
|
+
it('removes room when both sender and recipient disconnect', async () => {
|
|
477
|
+
const sender = await connect();
|
|
478
|
+
await authenticate(sender, 'gift_30', 'token_30', 'sender');
|
|
479
|
+
|
|
480
|
+
const recipient = await connect();
|
|
481
|
+
await authenticate(recipient, 'gift_30', 'token_30', 'recipient');
|
|
482
|
+
await waitForMessage(sender);
|
|
483
|
+
|
|
484
|
+
sender.close();
|
|
485
|
+
await waitForMessage(recipient); // peer offline
|
|
486
|
+
|
|
487
|
+
recipient.close();
|
|
488
|
+
// Wait a tick for cleanup
|
|
489
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
490
|
+
|
|
491
|
+
expect(rooms.has('gift_30')).toBe(false);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('keeps room alive when only one peer disconnects', async () => {
|
|
495
|
+
const sender = await connect();
|
|
496
|
+
await authenticate(sender, 'gift_31', 'token_31', 'sender');
|
|
497
|
+
|
|
498
|
+
const recipient = await connect();
|
|
499
|
+
await authenticate(recipient, 'gift_31', 'token_31', 'recipient');
|
|
500
|
+
await waitForMessage(sender);
|
|
501
|
+
|
|
502
|
+
recipient.close();
|
|
503
|
+
await waitForMessage(sender); // peer offline
|
|
504
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
505
|
+
|
|
506
|
+
expect(rooms.has('gift_31')).toBe(true);
|
|
507
|
+
|
|
508
|
+
sender.close();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('allows reconnection after peer disconnect', async () => {
|
|
512
|
+
const sender = await connect();
|
|
513
|
+
await authenticate(sender, 'gift_32', 'token_32', 'sender');
|
|
514
|
+
|
|
515
|
+
const recipient1 = await connect();
|
|
516
|
+
await authenticate(recipient1, 'gift_32', 'token_32', 'recipient');
|
|
517
|
+
await waitForMessage(sender);
|
|
518
|
+
|
|
519
|
+
recipient1.close();
|
|
520
|
+
await waitForMessage(sender); // peer offline
|
|
521
|
+
|
|
522
|
+
// New recipient connects
|
|
523
|
+
const recipient2 = await connect();
|
|
524
|
+
const result = await authenticate(recipient2, 'gift_32', 'token_32', 'recipient');
|
|
525
|
+
expect(result.success).toBe(true);
|
|
526
|
+
expect(result.peerOnline).toBe(true);
|
|
527
|
+
|
|
528
|
+
// Sender should get peer online again
|
|
529
|
+
const online = await waitForMessage(sender);
|
|
530
|
+
expect(online).toEqual({ type: 'gift:peer:status', online: true });
|
|
531
|
+
|
|
532
|
+
sender.close();
|
|
533
|
+
recipient2.close();
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe('Full Relay Flow', () => {
|
|
538
|
+
it('completes a full request-response cycle through the relay', async () => {
|
|
539
|
+
const sender = await connect();
|
|
540
|
+
await authenticate(sender, 'gift_40', 'token_40', 'sender');
|
|
541
|
+
|
|
542
|
+
const recipient = await connect();
|
|
543
|
+
await authenticate(recipient, 'gift_40', 'token_40', 'recipient');
|
|
544
|
+
await waitForMessage(sender); // peer online
|
|
545
|
+
|
|
546
|
+
// 1. Recipient sends a request
|
|
547
|
+
recipient.send(JSON.stringify({
|
|
548
|
+
type: 'relay:request',
|
|
549
|
+
id: 'req_full_1',
|
|
550
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
551
|
+
method: 'POST',
|
|
552
|
+
body: '{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"Hi"}]}',
|
|
553
|
+
}));
|
|
554
|
+
|
|
555
|
+
// 2. Sender receives the request
|
|
556
|
+
const request = await waitForMessage(sender);
|
|
557
|
+
expect(request.type).toBe('relay:request');
|
|
558
|
+
expect(request.id).toBe('req_full_1');
|
|
559
|
+
|
|
560
|
+
// Collect all messages on recipient
|
|
561
|
+
const recipientMessages: any[] = [];
|
|
562
|
+
const allReceived = new Promise<void>((resolve) => {
|
|
563
|
+
recipient.on('message', (data) => {
|
|
564
|
+
recipientMessages.push(JSON.parse(String(data)));
|
|
565
|
+
if (recipientMessages.length >= 5) resolve();
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// 3. Sender sends response meta
|
|
570
|
+
sender.send(JSON.stringify({
|
|
571
|
+
type: 'relay:response:meta',
|
|
572
|
+
id: 'req_full_1',
|
|
573
|
+
status: 200,
|
|
574
|
+
headers: { 'content-type': 'text/event-stream' },
|
|
575
|
+
}));
|
|
576
|
+
|
|
577
|
+
// 4. Sender streams response chunks
|
|
578
|
+
sender.send(JSON.stringify({
|
|
579
|
+
type: 'relay:response:chunk',
|
|
580
|
+
id: 'req_full_1',
|
|
581
|
+
data: 'data: {"type":"content_block_delta"}\n\n',
|
|
582
|
+
}));
|
|
583
|
+
|
|
584
|
+
sender.send(JSON.stringify({
|
|
585
|
+
type: 'relay:response:chunk',
|
|
586
|
+
id: 'req_full_1',
|
|
587
|
+
data: 'data: {"type":"message_stop"}\n\n',
|
|
588
|
+
}));
|
|
589
|
+
|
|
590
|
+
// 5. Sender signals done
|
|
591
|
+
sender.send(JSON.stringify({
|
|
592
|
+
type: 'relay:response:done',
|
|
593
|
+
id: 'req_full_1',
|
|
594
|
+
}));
|
|
595
|
+
|
|
596
|
+
// 6. Sender reports usage
|
|
597
|
+
sender.send(JSON.stringify({
|
|
598
|
+
type: 'gift:usage',
|
|
599
|
+
giftId: 'gift_40',
|
|
600
|
+
usedTokens: 150,
|
|
601
|
+
}));
|
|
602
|
+
|
|
603
|
+
// 7. Recipient receives everything in order
|
|
604
|
+
await allReceived;
|
|
605
|
+
|
|
606
|
+
expect(recipientMessages[0].type).toBe('relay:response:meta');
|
|
607
|
+
expect(recipientMessages[0].status).toBe(200);
|
|
608
|
+
expect(recipientMessages[1].type).toBe('relay:response:chunk');
|
|
609
|
+
expect(recipientMessages[2].type).toBe('relay:response:chunk');
|
|
610
|
+
expect(recipientMessages[3].type).toBe('relay:response:done');
|
|
611
|
+
expect(recipientMessages[4].type).toBe('gift:usage');
|
|
612
|
+
expect(recipientMessages[4].usedTokens).toBe(150);
|
|
613
|
+
|
|
614
|
+
sender.close();
|
|
615
|
+
recipient.close();
|
|
616
|
+
});
|
|
617
|
+
});
|