@dangao/bun-server 1.5.0 → 1.6.0

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.
@@ -4,7 +4,11 @@ import type { ServerWebSocket } from 'bun';
4
4
  import { WebSocketGateway, OnOpen, OnMessage, OnClose } from '../../src/websocket/decorators';
5
5
  import { WebSocketGatewayRegistry } from '../../src/websocket/registry';
6
6
  import { ControllerRegistry } from '../../src/controller/controller';
7
+ import { Application } from '../../src/core/application';
8
+ import { Query, Context } from '../../src/controller/decorators';
9
+ import { getTestPort } from '../utils/test-port';
7
10
  import type { WebSocketConnectionData } from '../../src/websocket/registry';
11
+ import type { Context as RequestContext } from '../../src/core/context';
8
12
 
9
13
  function createFakeSocket(path: string): ServerWebSocket<WebSocketConnectionData> {
10
14
  return {
@@ -65,4 +69,1024 @@ describe('WebSocketGatewayRegistry', () => {
65
69
  });
66
70
  });
67
71
 
72
+ @WebSocketGateway('/ws/chat')
73
+ class ChatGateway {
74
+ public static messages: string[] = [];
75
+
76
+ @OnOpen
77
+ public handleOpen(ws: ServerWebSocket<WebSocketConnectionData>): void {
78
+ ChatGateway.messages.push('connected');
79
+ ws.send('Welcome to chat!');
80
+ }
81
+
82
+ @OnMessage
83
+ public handleMessage(
84
+ ws: ServerWebSocket<WebSocketConnectionData>,
85
+ message: string,
86
+ ): void {
87
+ ChatGateway.messages.push(message);
88
+ ws.send(`[echo] ${message}`);
89
+ }
90
+
91
+ @OnClose
92
+ public handleClose(_ws: ServerWebSocket<WebSocketConnectionData>): void {
93
+ ChatGateway.messages.push('disconnected');
94
+ }
95
+ }
96
+
97
+ @WebSocketGateway('/ws/echo')
98
+ class EchoGateway {
99
+ public static echoes: string[] = [];
100
+
101
+ @OnMessage
102
+ public handleMessage(
103
+ ws: ServerWebSocket<WebSocketConnectionData>,
104
+ message: string,
105
+ ): void {
106
+ EchoGateway.echoes.push(message);
107
+ ws.send(`Echo: ${message}`);
108
+ }
109
+ }
110
+
111
+ describe('WebSocket Gateway Integration', () => {
112
+ let app: Application;
113
+ let port: number;
114
+
115
+ beforeEach(() => {
116
+ port = getTestPort();
117
+ app = new Application({ port });
118
+ ChatGateway.messages = [];
119
+ EchoGateway.echoes = [];
120
+ });
121
+
122
+ afterEach(async () => {
123
+ if (app) {
124
+ await app.stop();
125
+ }
126
+ WebSocketGatewayRegistry.getInstance().clear();
127
+ ControllerRegistry.getInstance().clear();
128
+ });
129
+
130
+ test('should support @WebSocketGateway decorator to register gateway', () => {
131
+ app.registerWebSocketGateway(ChatGateway);
132
+
133
+ const registry = WebSocketGatewayRegistry.getInstance();
134
+ expect(registry.hasGateway('/ws/chat')).toBe(true);
135
+ });
136
+
137
+ test('should handle WebSocket connection on registered path', async () => {
138
+ app.registerWebSocketGateway(ChatGateway);
139
+ await app.listen();
140
+
141
+ const ws = new WebSocket(`ws://localhost:${port}/ws/chat`);
142
+
143
+ await new Promise<void>((resolve, reject) => {
144
+ const timeout = setTimeout(() => {
145
+ reject(new Error('WebSocket connection timeout'));
146
+ }, 2000);
147
+
148
+ ws.onopen = () => {
149
+ clearTimeout(timeout);
150
+ resolve();
151
+ };
152
+
153
+ ws.onerror = (error) => {
154
+ clearTimeout(timeout);
155
+ reject(error);
156
+ };
157
+ });
158
+
159
+ expect(ChatGateway.messages).toContain('connected');
160
+ ws.close();
161
+ });
162
+
163
+ test('should handle WebSocket messages', async () => {
164
+ app.registerWebSocketGateway(ChatGateway);
165
+ await app.listen();
166
+
167
+ const ws = new WebSocket(`ws://localhost:${port}/ws/chat`);
168
+
169
+ await new Promise<void>((resolve, reject) => {
170
+ const timeout = setTimeout(() => {
171
+ reject(new Error('WebSocket connection timeout'));
172
+ }, 2000);
173
+
174
+ ws.onopen = () => {
175
+ clearTimeout(timeout);
176
+ resolve();
177
+ };
178
+
179
+ ws.onerror = (error) => {
180
+ clearTimeout(timeout);
181
+ reject(error);
182
+ };
183
+ });
184
+
185
+ // 等待连接建立
186
+ await new Promise((resolve) => setTimeout(resolve, 100));
187
+
188
+ const testMessage = 'Hello, WebSocket!';
189
+ ws.send(testMessage);
190
+
191
+ // 等待消息处理
192
+ await new Promise((resolve) => setTimeout(resolve, 200));
193
+
194
+ expect(ChatGateway.messages).toContain(testMessage);
195
+ ws.close();
196
+ });
197
+
198
+ test('should handle WebSocket close event', async () => {
199
+ app.registerWebSocketGateway(ChatGateway);
200
+ await app.listen();
201
+
202
+ const ws = new WebSocket(`ws://localhost:${port}/ws/chat`);
203
+
204
+ await new Promise<void>((resolve, reject) => {
205
+ const timeout = setTimeout(() => {
206
+ reject(new Error('WebSocket connection timeout'));
207
+ }, 2000);
208
+
209
+ ws.onopen = () => {
210
+ clearTimeout(timeout);
211
+ resolve();
212
+ };
213
+
214
+ ws.onerror = (error) => {
215
+ clearTimeout(timeout);
216
+ reject(error);
217
+ };
218
+ });
219
+
220
+ // 等待连接建立
221
+ await new Promise((resolve) => setTimeout(resolve, 100));
222
+
223
+ ws.close();
224
+
225
+ // 等待关闭事件处理
226
+ await new Promise((resolve) => setTimeout(resolve, 200));
227
+
228
+ expect(ChatGateway.messages).toContain('connected');
229
+ });
230
+
231
+ test('should support multiple gateways on different paths', async () => {
232
+ app.registerWebSocketGateway(ChatGateway);
233
+ app.registerWebSocketGateway(EchoGateway);
234
+ await app.listen();
235
+
236
+ const registry = WebSocketGatewayRegistry.getInstance();
237
+ expect(registry.hasGateway('/ws/chat')).toBe(true);
238
+ expect(registry.hasGateway('/ws/echo')).toBe(true);
239
+
240
+ // 测试 chat gateway
241
+ const chatWs = new WebSocket(`ws://localhost:${port}/ws/chat`);
242
+ await new Promise<void>((resolve, reject) => {
243
+ const timeout = setTimeout(() => {
244
+ reject(new Error('WebSocket connection timeout'));
245
+ }, 2000);
246
+
247
+ chatWs.onopen = () => {
248
+ clearTimeout(timeout);
249
+ resolve();
250
+ };
251
+
252
+ chatWs.onerror = (error) => {
253
+ clearTimeout(timeout);
254
+ reject(error);
255
+ };
256
+ });
257
+
258
+ await new Promise((resolve) => setTimeout(resolve, 100));
259
+ chatWs.send('chat message');
260
+ await new Promise((resolve) => setTimeout(resolve, 200));
261
+
262
+ // 测试 echo gateway
263
+ const echoWs = new WebSocket(`ws://localhost:${port}/ws/echo`);
264
+ await new Promise<void>((resolve, reject) => {
265
+ const timeout = setTimeout(() => {
266
+ reject(new Error('WebSocket connection timeout'));
267
+ }, 2000);
268
+
269
+ echoWs.onopen = () => {
270
+ clearTimeout(timeout);
271
+ resolve();
272
+ };
273
+
274
+ echoWs.onerror = (error) => {
275
+ clearTimeout(timeout);
276
+ reject(error);
277
+ };
278
+ });
279
+
280
+ await new Promise((resolve) => setTimeout(resolve, 100));
281
+ echoWs.send('echo message');
282
+ await new Promise((resolve) => setTimeout(resolve, 200));
283
+
284
+ expect(ChatGateway.messages).toContain('chat message');
285
+ expect(EchoGateway.echoes).toContain('echo message');
286
+
287
+ chatWs.close();
288
+ echoWs.close();
289
+ });
290
+
291
+ test('should return 404 for unregistered WebSocket path', async () => {
292
+ app.registerWebSocketGateway(ChatGateway);
293
+ await app.listen();
294
+
295
+ const response = await fetch(`http://localhost:${port}/ws/unknown`, {
296
+ headers: {
297
+ Upgrade: 'websocket',
298
+ Connection: 'Upgrade',
299
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
300
+ 'Sec-WebSocket-Version': '13',
301
+ },
302
+ });
303
+
304
+ expect(response.status).toBe(404);
305
+ const text = await response.text();
306
+ expect(text).toContain('WebSocket gateway not found');
307
+ });
308
+
309
+ test('should handle WebSocket message with binary data', async () => {
310
+ app.registerWebSocketGateway(EchoGateway);
311
+ await app.listen();
312
+
313
+ const ws = new WebSocket(`ws://localhost:${port}/ws/echo`);
314
+
315
+ await new Promise<void>((resolve, reject) => {
316
+ const timeout = setTimeout(() => {
317
+ reject(new Error('WebSocket connection timeout'));
318
+ }, 2000);
319
+
320
+ ws.onopen = () => {
321
+ clearTimeout(timeout);
322
+ resolve();
323
+ };
324
+
325
+ ws.onerror = (error) => {
326
+ clearTimeout(timeout);
327
+ reject(error);
328
+ };
329
+ });
330
+
331
+ await new Promise((resolve) => setTimeout(resolve, 100));
332
+
333
+ const binaryData = new Uint8Array([1, 2, 3, 4, 5]);
334
+ ws.send(binaryData);
335
+
336
+ await new Promise((resolve) => setTimeout(resolve, 200));
337
+
338
+ // 二进制数据会被转换为字符串或 ArrayBuffer
339
+ // 这里主要验证不会抛出错误
340
+ expect(EchoGateway.echoes.length).toBeGreaterThan(0);
341
+
342
+ ws.close();
343
+ });
344
+
345
+ test('should support method-level event handlers', async () => {
346
+ @WebSocketGateway('/ws/method-test')
347
+ class MethodLevelGateway {
348
+ public static events: string[] = [];
349
+
350
+ @OnOpen
351
+ public onConnection(ws: ServerWebSocket<WebSocketConnectionData>): void {
352
+ MethodLevelGateway.events.push('connection-opened');
353
+ ws.send('Connected via method handler');
354
+ }
355
+
356
+ @OnMessage
357
+ public onMessageReceived(
358
+ ws: ServerWebSocket<WebSocketConnectionData>,
359
+ message: string,
360
+ ): void {
361
+ MethodLevelGateway.events.push(`message-received:${message}`);
362
+ ws.send(`Method handler received: ${message}`);
363
+ }
364
+
365
+ @OnClose
366
+ public onConnectionClosed(
367
+ _ws: ServerWebSocket<WebSocketConnectionData>,
368
+ code: number,
369
+ reason: string,
370
+ ): void {
371
+ MethodLevelGateway.events.push(`connection-closed:${code}:${reason}`);
372
+ }
373
+ }
374
+
375
+ app.registerWebSocketGateway(MethodLevelGateway);
376
+ await app.listen();
377
+
378
+ const ws = new WebSocket(`ws://localhost:${port}/ws/method-test`);
379
+
380
+ await new Promise<void>((resolve, reject) => {
381
+ const timeout = setTimeout(() => {
382
+ reject(new Error('WebSocket connection timeout'));
383
+ }, 2000);
384
+
385
+ ws.onopen = () => {
386
+ clearTimeout(timeout);
387
+ resolve();
388
+ };
389
+
390
+ ws.onerror = (error) => {
391
+ clearTimeout(timeout);
392
+ reject(error);
393
+ };
394
+ });
395
+
396
+ await new Promise((resolve) => setTimeout(resolve, 100));
397
+ expect(MethodLevelGateway.events).toContain('connection-opened');
398
+
399
+ ws.send('test-message');
400
+ await new Promise((resolve) => setTimeout(resolve, 200));
401
+ expect(MethodLevelGateway.events).toContain('message-received:test-message');
402
+
403
+ ws.close(1000, 'test-close');
404
+ await new Promise((resolve) => setTimeout(resolve, 200));
405
+ expect(MethodLevelGateway.events.some((e) => e.includes('connection-closed'))).toBe(true);
406
+ });
407
+
408
+ test('should extract and provide path information from WebSocket URL', async () => {
409
+ // 注意:当前实现使用精确路径匹配,不支持动态路径参数(如 :roomId)
410
+ // 这个测试验证路径信息是否被正确保存到 ws.data.path
411
+ @WebSocketGateway('/ws/rooms')
412
+ class RoomGateway {
413
+ public static paths: string[] = [];
414
+
415
+ @OnOpen
416
+ public handleOpen(ws: ServerWebSocket<WebSocketConnectionData>): void {
417
+ const path = ws.data?.path || '';
418
+ RoomGateway.paths.push(path);
419
+ ws.send(`Joined room from path: ${path}`);
420
+ }
421
+
422
+ @OnMessage
423
+ public handleMessage(
424
+ ws: ServerWebSocket<WebSocketConnectionData>,
425
+ message: string,
426
+ ): void {
427
+ const path = ws.data?.path || '';
428
+ ws.send(`Room ${path}: ${message}`);
429
+ }
430
+ }
431
+
432
+ app.registerWebSocketGateway(RoomGateway);
433
+ await app.listen();
434
+
435
+ const ws = new WebSocket(`ws://localhost:${port}/ws/rooms`);
436
+
437
+ await new Promise<void>((resolve, reject) => {
438
+ const timeout = setTimeout(() => {
439
+ reject(new Error('WebSocket connection timeout'));
440
+ }, 2000);
441
+
442
+ ws.onopen = () => {
443
+ clearTimeout(timeout);
444
+ resolve();
445
+ };
446
+
447
+ ws.onerror = (error) => {
448
+ clearTimeout(timeout);
449
+ reject(error);
450
+ };
451
+ });
452
+
453
+ await new Promise((resolve) => setTimeout(resolve, 200));
454
+
455
+ // 验证路径被正确保存
456
+ expect(RoomGateway.paths.length).toBeGreaterThan(0);
457
+ expect(RoomGateway.paths[0]).toBe('/ws/rooms');
458
+ ws.close();
459
+ });
460
+
461
+ test('should extract and provide query parameters from WebSocket URL', async () => {
462
+ @WebSocketGateway('/ws/query-test')
463
+ class QueryParamGateway {
464
+ public static queryParams: Record<string, string>[] = [];
465
+ public static paths: string[] = [];
466
+
467
+ @OnOpen
468
+ public handleOpen(ws: ServerWebSocket<WebSocketConnectionData>): void {
469
+ const path = ws.data?.path || '';
470
+ QueryParamGateway.paths.push(path);
471
+
472
+ // 尝试从 URL 中提取查询参数
473
+ // 注意:当前实现可能不支持,这个测试可以验证功能是否存在
474
+ const urlMatch = path.match(/\?(.+)$/);
475
+ if (urlMatch) {
476
+ const params: Record<string, string> = {};
477
+ urlMatch[1].split('&').forEach((pair) => {
478
+ const [key, value] = pair.split('=');
479
+ if (key && value) {
480
+ params[decodeURIComponent(key)] = decodeURIComponent(value);
481
+ }
482
+ });
483
+ QueryParamGateway.queryParams.push(params);
484
+ }
485
+
486
+ ws.send(`Query params extracted: ${JSON.stringify(QueryParamGateway.queryParams)}`);
487
+ }
488
+
489
+ @OnMessage
490
+ public handleMessage(
491
+ ws: ServerWebSocket<WebSocketConnectionData>,
492
+ message: string,
493
+ ): void {
494
+ ws.send(`Received with query: ${message}`);
495
+ }
496
+ }
497
+
498
+ app.registerWebSocketGateway(QueryParamGateway);
499
+ await app.listen();
500
+
501
+ const userId = 'user-456';
502
+ const token = 'token-789';
503
+ const ws = new WebSocket(
504
+ `ws://localhost:${port}/ws/query-test?userId=${userId}&token=${token}`,
505
+ );
506
+
507
+ await new Promise<void>((resolve, reject) => {
508
+ const timeout = setTimeout(() => {
509
+ reject(new Error('WebSocket connection timeout'));
510
+ }, 2000);
511
+
512
+ ws.onopen = () => {
513
+ clearTimeout(timeout);
514
+ resolve();
515
+ };
516
+
517
+ ws.onerror = (error) => {
518
+ clearTimeout(timeout);
519
+ reject(error);
520
+ };
521
+ });
522
+
523
+ await new Promise((resolve) => setTimeout(resolve, 200));
524
+
525
+ // 验证路径被保存(可能包含或不包含查询参数,取决于实现)
526
+ expect(QueryParamGateway.paths.length).toBeGreaterThan(0);
527
+ // 如果支持查询参数提取,应该能提取到参数
528
+ // 注意:当前实现可能不支持,这个测试可以验证功能是否存在
529
+ ws.close();
530
+ });
531
+
532
+ test('should support path parameter extraction (manual parsing)', async () => {
533
+ // 注意:当前实现不支持动态路径参数和 @Param 装饰器
534
+ // 这个测试演示如何手动从路径中提取参数,作为未来功能的参考
535
+ @WebSocketGateway('/ws/param-test')
536
+ class ParamGateway {
537
+ public static receivedParams: string[] = [];
538
+ public static extractedIds: string[] = [];
539
+
540
+ @OnOpen
541
+ public handleOpen(ws: ServerWebSocket<WebSocketConnectionData>): void {
542
+ const path = ws.data?.path || '';
543
+ // 手动从路径中提取参数(如果路径包含参数)
544
+ // 例如:如果路径是 /ws/param-test/123,提取 123
545
+ const match = path.match(/\/ws\/param-test\/([^/?]+)/);
546
+ if (match) {
547
+ ParamGateway.extractedIds.push(match[1]);
548
+ }
549
+ }
550
+
551
+ @OnMessage
552
+ public handleMessage(
553
+ _ws: ServerWebSocket<WebSocketConnectionData>,
554
+ message: string,
555
+ ): void {
556
+ ParamGateway.receivedParams.push(message);
557
+ }
558
+ }
559
+
560
+ app.registerWebSocketGateway(ParamGateway);
561
+ await app.listen();
562
+
563
+ // 使用精确路径(当前实现要求)
564
+ const ws = new WebSocket(`ws://localhost:${port}/ws/param-test`);
565
+
566
+ await new Promise<void>((resolve, reject) => {
567
+ const timeout = setTimeout(() => {
568
+ reject(new Error('WebSocket connection timeout'));
569
+ }, 2000);
570
+
571
+ ws.onopen = () => {
572
+ clearTimeout(timeout);
573
+ resolve();
574
+ };
575
+
576
+ ws.onerror = (error) => {
577
+ clearTimeout(timeout);
578
+ reject(error);
579
+ };
580
+ });
581
+
582
+ await new Promise((resolve) => setTimeout(resolve, 100));
583
+ ws.send('test-message');
584
+ await new Promise((resolve) => setTimeout(resolve, 200));
585
+
586
+ // 验证消息被接收
587
+ expect(ParamGateway.receivedParams).toContain('test-message');
588
+ ws.close();
589
+ });
590
+
591
+ test('should support @Query decorator for query parameters', async () => {
592
+ // 注意:这个测试假设 WebSocket 支持 @Query 装饰器
593
+ // 如果功能不存在,测试会失败,这样可以明确需要实现该功能
594
+ @WebSocketGateway('/ws/query-decorator-test')
595
+ class QueryDecoratorGateway {
596
+ public static receivedQueries: Record<string, string>[] = [];
597
+
598
+ @OnMessage
599
+ public handleMessage(
600
+ _ws: ServerWebSocket<WebSocketConnectionData>,
601
+ message: string,
602
+ // 假设支持 @Query 装饰器
603
+ // @Query('userId') userId: string,
604
+ // @Query('token') token: string,
605
+ ): void {
606
+ // 如果支持 @Query,这里应该能获取到查询参数
607
+ // 当前实现可能不支持,这个测试可以验证功能是否存在
608
+ QueryDecoratorGateway.receivedQueries.push({ message });
609
+ }
610
+ }
611
+
612
+ app.registerWebSocketGateway(QueryDecoratorGateway);
613
+ await app.listen();
614
+
615
+ const userId = 'user-999';
616
+ const token = 'token-888';
617
+ const ws = new WebSocket(
618
+ `ws://localhost:${port}/ws/query-decorator-test?userId=${userId}&token=${token}`,
619
+ );
620
+
621
+ await new Promise<void>((resolve, reject) => {
622
+ const timeout = setTimeout(() => {
623
+ reject(new Error('WebSocket connection timeout'));
624
+ }, 2000);
625
+
626
+ ws.onopen = () => {
627
+ clearTimeout(timeout);
628
+ resolve();
629
+ };
630
+
631
+ ws.onerror = (error) => {
632
+ clearTimeout(timeout);
633
+ reject(error);
634
+ };
635
+ });
636
+
637
+ await new Promise((resolve) => setTimeout(resolve, 100));
638
+ ws.send('test-query');
639
+ await new Promise((resolve) => setTimeout(resolve, 200));
640
+
641
+ // 验证消息被接收(查询参数提取功能可能需要额外实现)
642
+ expect(QueryDecoratorGateway.receivedQueries.length).toBeGreaterThan(0);
643
+ ws.close();
644
+ });
645
+
646
+ test('should support @Context decorator for accessing request context', async () => {
647
+ // 注意:这个测试假设 WebSocket 支持 @Context 装饰器
648
+ // 如果功能不存在,测试会失败,这样可以明确需要实现该功能
649
+ @WebSocketGateway('/ws/context-test')
650
+ class ContextGateway {
651
+ public static contexts: unknown[] = [];
652
+
653
+ @OnOpen
654
+ public handleOpen(
655
+ _ws: ServerWebSocket<WebSocketConnectionData>,
656
+ // 假设支持 @Context 装饰器
657
+ // @Context() context: Context,
658
+ ): void {
659
+ // 如果支持 @Context,这里应该能获取到 Context 对象
660
+ // 当前实现可能不支持,这个测试可以验证功能是否存在
661
+ ContextGateway.contexts.push('open');
662
+ }
663
+
664
+ @OnMessage
665
+ public handleMessage(
666
+ _ws: ServerWebSocket<WebSocketConnectionData>,
667
+ message: string,
668
+ // 假设支持 @Context 装饰器
669
+ // @Context() context: Context,
670
+ ): void {
671
+ // 如果支持 @Context,这里应该能访问 context.query, context.headers 等
672
+ // 当前实现可能不支持,这个测试可以验证功能是否存在
673
+ ContextGateway.contexts.push(`message:${message}`);
674
+ }
675
+ }
676
+
677
+ app.registerWebSocketGateway(ContextGateway);
678
+ await app.listen();
679
+
680
+ const ws = new WebSocket(`ws://localhost:${port}/ws/context-test?key=value`);
681
+
682
+ await new Promise<void>((resolve, reject) => {
683
+ const timeout = setTimeout(() => {
684
+ reject(new Error('WebSocket connection timeout'));
685
+ }, 2000);
686
+
687
+ ws.onopen = () => {
688
+ clearTimeout(timeout);
689
+ resolve();
690
+ };
691
+
692
+ ws.onerror = (error) => {
693
+ clearTimeout(timeout);
694
+ reject(error);
695
+ };
696
+ });
697
+
698
+ await new Promise((resolve) => setTimeout(resolve, 100));
699
+ ws.send('test-context');
700
+ await new Promise((resolve) => setTimeout(resolve, 200));
701
+
702
+ // 验证事件被处理(Context 功能可能需要额外实现)
703
+ expect(ContextGateway.contexts.length).toBeGreaterThan(0);
704
+ expect(ContextGateway.contexts).toContain('open');
705
+ expect(ContextGateway.contexts.some((c) => String(c).includes('test-context'))).toBe(true);
706
+ ws.close();
707
+ });
708
+
709
+ test('should handle query parameters with path information together', async () => {
710
+ // 注意:当前实现不支持动态路径参数,但支持查询参数
711
+ @WebSocketGateway('/ws/combined')
712
+ class CombinedGateway {
713
+ public static data: {
714
+ path?: string;
715
+ query?: Record<string, string>;
716
+ }[] = [];
717
+
718
+ @OnOpen
719
+ public handleOpen(ws: ServerWebSocket<WebSocketConnectionData>): void {
720
+ const path = ws.data?.path || '';
721
+ const data: {
722
+ path?: string;
723
+ query?: Record<string, string>;
724
+ } = { path };
725
+
726
+ // 提取查询参数(从完整 URL 中)
727
+ // 注意:ws.data.path 可能包含或不包含查询参数,取决于实现
728
+ const queryMatch = path.match(/\?(.+)$/);
729
+ if (queryMatch) {
730
+ const params: Record<string, string> = {};
731
+ queryMatch[1].split('&').forEach((pair) => {
732
+ const [key, value] = pair.split('=');
733
+ if (key && value) {
734
+ params[decodeURIComponent(key)] = decodeURIComponent(value);
735
+ }
736
+ });
737
+ data.query = params;
738
+ }
739
+
740
+ CombinedGateway.data.push(data);
741
+ ws.send(`Combined: ${JSON.stringify(data)}`);
742
+ }
743
+
744
+ @OnMessage
745
+ public handleMessage(
746
+ ws: ServerWebSocket<WebSocketConnectionData>,
747
+ message: string,
748
+ ): void {
749
+ ws.send(`Message: ${message}`);
750
+ }
751
+ }
752
+
753
+ app.registerWebSocketGateway(CombinedGateway);
754
+ await app.listen();
755
+
756
+ const userId = 'user-xyz';
757
+ const ws = new WebSocket(
758
+ `ws://localhost:${port}/ws/combined?userId=${userId}`,
759
+ );
760
+
761
+ await new Promise<void>((resolve, reject) => {
762
+ const timeout = setTimeout(() => {
763
+ reject(new Error('WebSocket connection timeout'));
764
+ }, 2000);
765
+
766
+ ws.onopen = () => {
767
+ clearTimeout(timeout);
768
+ resolve();
769
+ };
770
+
771
+ ws.onerror = (error) => {
772
+ clearTimeout(timeout);
773
+ reject(error);
774
+ };
775
+ });
776
+
777
+ await new Promise((resolve) => setTimeout(resolve, 200));
778
+
779
+ // 验证数据被收集
780
+ expect(CombinedGateway.data.length).toBeGreaterThan(0);
781
+ expect(CombinedGateway.data[0].path).toBeDefined();
782
+ ws.close();
783
+ });
784
+
785
+ test('should create new instance for each WebSocket connection (dynamic instance creation)', async () => {
786
+ @WebSocketGateway('/ws/dynamic-instance')
787
+ class DynamicInstanceGateway {
788
+ public static instanceIds: number[] = [];
789
+ public static callCounts: Map<number, number> = new Map();
790
+ private readonly instanceId: number;
791
+
792
+ public constructor() {
793
+ this.instanceId = Math.random();
794
+ DynamicInstanceGateway.instanceIds.push(this.instanceId);
795
+ DynamicInstanceGateway.callCounts.set(this.instanceId, 0);
796
+ }
797
+
798
+ @OnOpen
799
+ public handleOpen(ws: ServerWebSocket<WebSocketConnectionData>): void {
800
+ const count = DynamicInstanceGateway.callCounts.get(this.instanceId) || 0;
801
+ DynamicInstanceGateway.callCounts.set(this.instanceId, count + 1);
802
+ ws.send(`Instance ID: ${this.instanceId}`);
803
+ }
804
+
805
+ @OnMessage
806
+ public handleMessage(
807
+ _ws: ServerWebSocket<WebSocketConnectionData>,
808
+ message: string,
809
+ ): void {
810
+ const count = DynamicInstanceGateway.callCounts.get(this.instanceId) || 0;
811
+ DynamicInstanceGateway.callCounts.set(this.instanceId, count + 1);
812
+ }
813
+ }
814
+
815
+ app.registerWebSocketGateway(DynamicInstanceGateway);
816
+ await app.listen();
817
+
818
+ // 创建第一个连接
819
+ const ws1 = new WebSocket(`ws://localhost:${port}/ws/dynamic-instance`);
820
+ await new Promise<void>((resolve, reject) => {
821
+ const timeout = setTimeout(() => {
822
+ reject(new Error('WebSocket connection timeout'));
823
+ }, 2000);
824
+
825
+ ws1.onopen = () => {
826
+ clearTimeout(timeout);
827
+ resolve();
828
+ };
829
+
830
+ ws1.onerror = (error) => {
831
+ clearTimeout(timeout);
832
+ reject(error);
833
+ };
834
+ });
835
+
836
+ await new Promise((resolve) => setTimeout(resolve, 200));
837
+ const firstInstanceCount = DynamicInstanceGateway.instanceIds.length;
838
+
839
+ // 创建第二个连接
840
+ const ws2 = new WebSocket(`ws://localhost:${port}/ws/dynamic-instance`);
841
+ await new Promise<void>((resolve, reject) => {
842
+ const timeout = setTimeout(() => {
843
+ reject(new Error('WebSocket connection timeout'));
844
+ }, 2000);
845
+
846
+ ws2.onopen = () => {
847
+ clearTimeout(timeout);
848
+ resolve();
849
+ };
850
+
851
+ ws2.onerror = (error) => {
852
+ clearTimeout(timeout);
853
+ reject(error);
854
+ };
855
+ });
856
+
857
+ await new Promise((resolve) => setTimeout(resolve, 200));
858
+
859
+ // 验证创建了实例(至少一个)
860
+ expect(DynamicInstanceGateway.instanceIds.length).toBeGreaterThan(0);
861
+ // 验证每个连接都调用了处理器(说明实例被使用)
862
+ const totalCalls = Array.from(DynamicInstanceGateway.callCounts.values()).reduce(
863
+ (sum, count) => sum + count,
864
+ 0,
865
+ );
866
+ expect(totalCalls).toBeGreaterThan(0);
867
+
868
+ ws1.close();
869
+ ws2.close();
870
+ });
871
+
872
+ test('should support @Query decorator in WebSocket handlers', async () => {
873
+ @WebSocketGateway('/ws/query-decorator')
874
+ class QueryDecoratorGateway {
875
+ public static receivedQueries: Record<string, string>[] = [];
876
+
877
+ @OnOpen
878
+ public handleOpen(
879
+ _ws: ServerWebSocket<WebSocketConnectionData>,
880
+ @Query('userId') userId: string | null,
881
+ @Query('token') token: string | null,
882
+ ): void {
883
+ QueryDecoratorGateway.receivedQueries.push({
884
+ userId: userId || '',
885
+ token: token || '',
886
+ });
887
+ }
888
+
889
+ @OnMessage
890
+ public handleMessage(
891
+ _ws: ServerWebSocket<WebSocketConnectionData>,
892
+ message: string,
893
+ @Query('userId') userId: string | null,
894
+ ): void {
895
+ QueryDecoratorGateway.receivedQueries.push({
896
+ message,
897
+ userId: userId || '',
898
+ });
899
+ }
900
+ }
901
+
902
+ app.registerWebSocketGateway(QueryDecoratorGateway);
903
+ await app.listen();
904
+
905
+ const userId = 'user-123';
906
+ const token = 'token-456';
907
+ const ws = new WebSocket(
908
+ `ws://localhost:${port}/ws/query-decorator?userId=${userId}&token=${token}`,
909
+ );
910
+
911
+ await new Promise<void>((resolve, reject) => {
912
+ const timeout = setTimeout(() => {
913
+ reject(new Error('WebSocket connection timeout'));
914
+ }, 2000);
915
+
916
+ ws.onopen = () => {
917
+ clearTimeout(timeout);
918
+ resolve();
919
+ };
920
+
921
+ ws.onerror = (error) => {
922
+ clearTimeout(timeout);
923
+ reject(error);
924
+ };
925
+ });
926
+
927
+ await new Promise((resolve) => setTimeout(resolve, 200));
928
+
929
+ // 验证 @Query 装饰器正确提取了查询参数
930
+ expect(QueryDecoratorGateway.receivedQueries.length).toBeGreaterThan(0);
931
+ const openQuery = QueryDecoratorGateway.receivedQueries.find(
932
+ (q) => q.userId === userId && q.token === token,
933
+ );
934
+ expect(openQuery).toBeDefined();
935
+
936
+ ws.send('test-message');
937
+ await new Promise((resolve) => setTimeout(resolve, 200));
938
+
939
+ // 验证消息处理中的 @Query 也工作
940
+ const messageQuery = QueryDecoratorGateway.receivedQueries.find(
941
+ (q) => q.message === 'test-message' && q.userId === userId,
942
+ );
943
+ expect(messageQuery).toBeDefined();
944
+
945
+ ws.close();
946
+ });
947
+
948
+ test('should support @Context decorator in WebSocket handlers', async () => {
949
+ @WebSocketGateway('/ws/context-decorator')
950
+ class ContextDecoratorGateway {
951
+ public static contexts: {
952
+ path?: string;
953
+ query?: string;
954
+ hasContext?: boolean;
955
+ }[] = [];
956
+
957
+ @OnOpen
958
+ public handleOpen(
959
+ _ws: ServerWebSocket<WebSocketConnectionData>,
960
+ @Context() context: RequestContext,
961
+ ): void {
962
+ ContextDecoratorGateway.contexts.push({
963
+ path: context.path,
964
+ query: context.getQuery('userId') || undefined,
965
+ hasContext: context !== undefined,
966
+ });
967
+ }
968
+
969
+ @OnMessage
970
+ public handleMessage(
971
+ _ws: ServerWebSocket<WebSocketConnectionData>,
972
+ message: string,
973
+ @Context() context: RequestContext,
974
+ ): void {
975
+ ContextDecoratorGateway.contexts.push({
976
+ path: context.path,
977
+ query: context.getQuery('userId') || undefined,
978
+ hasContext: context !== undefined,
979
+ });
980
+ }
981
+ }
982
+
983
+ app.registerWebSocketGateway(ContextDecoratorGateway);
984
+ await app.listen();
985
+
986
+ const userId = 'user-789';
987
+ const ws = new WebSocket(
988
+ `ws://localhost:${port}/ws/context-decorator?userId=${userId}`,
989
+ );
990
+
991
+ await new Promise<void>((resolve, reject) => {
992
+ const timeout = setTimeout(() => {
993
+ reject(new Error('WebSocket connection timeout'));
994
+ }, 2000);
995
+
996
+ ws.onopen = () => {
997
+ clearTimeout(timeout);
998
+ resolve();
999
+ };
1000
+
1001
+ ws.onerror = (error) => {
1002
+ clearTimeout(timeout);
1003
+ reject(error);
1004
+ };
1005
+ });
1006
+
1007
+ await new Promise((resolve) => setTimeout(resolve, 200));
1008
+
1009
+ // 验证 @Context 装饰器正确注入了 Context 对象
1010
+ expect(ContextDecoratorGateway.contexts.length).toBeGreaterThan(0);
1011
+ const openContext = ContextDecoratorGateway.contexts[0];
1012
+ expect(openContext.hasContext).toBe(true);
1013
+ expect(openContext.path).toBe('/ws/context-decorator');
1014
+ expect(openContext.query).toBe(userId);
1015
+
1016
+ ws.send('test-context');
1017
+ await new Promise((resolve) => setTimeout(resolve, 200));
1018
+
1019
+ // 验证消息处理中的 @Context 也工作
1020
+ const messageContext = ContextDecoratorGateway.contexts.find(
1021
+ (c) => c.path === '/ws/context-decorator' && c.query === userId,
1022
+ );
1023
+ expect(messageContext).toBeDefined();
1024
+ expect(messageContext?.hasContext).toBe(true);
1025
+
1026
+ ws.close();
1027
+ });
1028
+
1029
+ test('should support @Query and @Context decorators together', async () => {
1030
+ @WebSocketGateway('/ws/combined-decorators')
1031
+ class CombinedDecoratorsGateway {
1032
+ public static results: {
1033
+ queryValue?: string | null;
1034
+ contextPath?: string;
1035
+ hasContext?: boolean;
1036
+ }[] = [];
1037
+
1038
+ @OnMessage
1039
+ public handleMessage(
1040
+ _ws: ServerWebSocket<WebSocketConnectionData>,
1041
+ message: string,
1042
+ @Query('roomId') roomId: string | null,
1043
+ @Context() context: RequestContext,
1044
+ ): void {
1045
+ CombinedDecoratorsGateway.results.push({
1046
+ queryValue: roomId,
1047
+ contextPath: context.path,
1048
+ hasContext: context !== undefined,
1049
+ });
1050
+ }
1051
+ }
1052
+
1053
+ app.registerWebSocketGateway(CombinedDecoratorsGateway);
1054
+ await app.listen();
1055
+
1056
+ const roomId = 'room-abc';
1057
+ const ws = new WebSocket(
1058
+ `ws://localhost:${port}/ws/combined-decorators?roomId=${roomId}`,
1059
+ );
1060
+
1061
+ await new Promise<void>((resolve, reject) => {
1062
+ const timeout = setTimeout(() => {
1063
+ reject(new Error('WebSocket connection timeout'));
1064
+ }, 2000);
1065
+
1066
+ ws.onopen = () => {
1067
+ clearTimeout(timeout);
1068
+ resolve();
1069
+ };
1070
+
1071
+ ws.onerror = (error) => {
1072
+ clearTimeout(timeout);
1073
+ reject(error);
1074
+ };
1075
+ });
1076
+
1077
+ await new Promise((resolve) => setTimeout(resolve, 100));
1078
+ ws.send('test-combined');
1079
+ await new Promise((resolve) => setTimeout(resolve, 200));
1080
+
1081
+ // 验证 @Query 和 @Context 同时工作
1082
+ expect(CombinedDecoratorsGateway.results.length).toBeGreaterThan(0);
1083
+ const result = CombinedDecoratorsGateway.results[0];
1084
+ expect(result.queryValue).toBe(roomId);
1085
+ expect(result.contextPath).toBe('/ws/combined-decorators');
1086
+ expect(result.hasContext).toBe(true);
1087
+
1088
+ ws.close();
1089
+ });
1090
+ });
1091
+
68
1092