@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byoky/relay",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "WebSocket relay server for Byoky",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
@@ -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
+ });