@onebun/core 0.1.1 → 0.1.2

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.
@@ -0,0 +1,658 @@
1
+ /**
2
+ * WebSocket Handler
3
+ *
4
+ * Handles WebSocket connections and message routing for gateways.
5
+ */
6
+
7
+ /* eslint-disable @typescript-eslint/naming-convention */
8
+ /* eslint-disable no-magic-numbers */
9
+
10
+ import type { WsStorageAdapter } from './ws-storage';
11
+ import type {
12
+ WsClientData,
13
+ WsHandlerMetadata,
14
+ WebSocketApplicationOptions,
15
+ } from './ws.types';
16
+ import type { Server, ServerWebSocket } from 'bun';
17
+
18
+ import type { SyncLogger } from '@onebun/logger';
19
+
20
+ import { BaseWebSocketGateway } from './ws-base-gateway';
21
+ import { getGatewayMetadata, isWebSocketGateway } from './ws-decorators';
22
+ import { WsExecutionContextImpl, executeGuards } from './ws-guards';
23
+ import { matchPattern } from './ws-pattern-matcher';
24
+ import {
25
+ parseMessage,
26
+ createOpenPacket,
27
+ createHandshake,
28
+ createPongPacket,
29
+ createFullAckMessage,
30
+ createFullEventMessage,
31
+ EngineIOPacketType,
32
+ SocketIOPacketType,
33
+ isNativeMessage,
34
+ parseNativeMessage,
35
+ createNativeMessage,
36
+ DEFAULT_PING_INTERVAL,
37
+ DEFAULT_PING_TIMEOUT,
38
+ DEFAULT_MAX_PAYLOAD,
39
+ } from './ws-socketio-protocol';
40
+ import { InMemoryWsStorage } from './ws-storage-memory';
41
+ import {
42
+ WsHandlerType,
43
+ WsParamType,
44
+ isWsHandlerResponse,
45
+ } from './ws.types';
46
+
47
+ // Alias for clarity
48
+ const ParamType = WsParamType;
49
+ const HandlerType = WsHandlerType;
50
+
51
+
52
+ /**
53
+ * Gateway instance with metadata
54
+ */
55
+ interface GatewayInstance {
56
+ instance: BaseWebSocketGateway;
57
+ metadata: ReturnType<typeof getGatewayMetadata>;
58
+ handlers: Map<WsHandlerType, WsHandlerMetadata[]>;
59
+ }
60
+
61
+ /**
62
+ * WebSocket handler for OneBunApplication
63
+ */
64
+ export class WsHandler {
65
+ private storage: WsStorageAdapter;
66
+ private gateways: Map<string, GatewayInstance> = new Map();
67
+ private pingIntervalMs: number;
68
+ private pingTimeoutMs: number;
69
+ private maxPayload: number;
70
+ private pingIntervals: Map<string, ReturnType<typeof setInterval>> = new Map();
71
+
72
+ constructor(
73
+ private logger: SyncLogger,
74
+ private options: WebSocketApplicationOptions = {},
75
+ ) {
76
+ this.storage = new InMemoryWsStorage();
77
+ this.pingIntervalMs = options.pingInterval ?? DEFAULT_PING_INTERVAL;
78
+ this.pingTimeoutMs = options.pingTimeout ?? DEFAULT_PING_TIMEOUT;
79
+ this.maxPayload = options.maxPayload ?? DEFAULT_MAX_PAYLOAD;
80
+ }
81
+
82
+ /**
83
+ * Set storage adapter
84
+ */
85
+ setStorage(storage: WsStorageAdapter): void {
86
+ this.storage = storage;
87
+ }
88
+
89
+ /**
90
+ * Register a gateway instance
91
+ */
92
+ registerGateway(gatewayClass: Function, instance: BaseWebSocketGateway): void {
93
+ const metadata = getGatewayMetadata(gatewayClass);
94
+ if (!metadata) {
95
+ return;
96
+ }
97
+
98
+ // Group handlers by type
99
+ const handlers = new Map<WsHandlerType, WsHandlerMetadata[]>();
100
+ for (const type of Object.values(WsHandlerType)) {
101
+ handlers.set(type as WsHandlerType, []);
102
+ }
103
+
104
+ for (const handler of metadata.handlers) {
105
+ const typeHandlers = handlers.get(handler.type) || [];
106
+ typeHandlers.push(handler);
107
+ handlers.set(handler.type, typeHandlers);
108
+ }
109
+
110
+ const key = metadata.namespace ? `${metadata.path}:${metadata.namespace}` : metadata.path;
111
+ this.gateways.set(key, { instance, metadata, handlers });
112
+
113
+ this.logger.debug(`Registered WebSocket gateway: ${gatewayClass.name} at ${metadata.path}`);
114
+ }
115
+
116
+ /**
117
+ * Initialize gateways with server
118
+ */
119
+ initializeGateways(server: Server): void {
120
+ for (const [_, gateway] of this.gateways) {
121
+ gateway.instance._initialize(this.storage, server);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Check if there are any registered gateways
127
+ */
128
+ hasGateways(): boolean {
129
+ return this.gateways.size > 0;
130
+ }
131
+
132
+ /**
133
+ * Get gateway for a path
134
+ */
135
+ private getGatewayForPath(path: string, namespace?: string): GatewayInstance | undefined {
136
+ // Try exact match with namespace
137
+ if (namespace) {
138
+ const key = `${path}:${namespace}`;
139
+ if (this.gateways.has(key)) {
140
+ return this.gateways.get(key);
141
+ }
142
+ }
143
+
144
+ // Try exact path match
145
+ if (this.gateways.has(path)) {
146
+ return this.gateways.get(path);
147
+ }
148
+
149
+ // Try prefix match
150
+ for (const [_, gateway] of this.gateways) {
151
+ if (gateway.metadata && path.startsWith(gateway.metadata.path)) {
152
+ return gateway;
153
+ }
154
+ }
155
+
156
+ return undefined;
157
+ }
158
+
159
+ /**
160
+ * Create WebSocket handlers for Bun.serve
161
+ */
162
+ createWebSocketHandlers(): {
163
+ open: (ws: ServerWebSocket<WsClientData>) => void;
164
+ message: (ws: ServerWebSocket<WsClientData>, message: string | Buffer) => void;
165
+ close: (ws: ServerWebSocket<WsClientData>, code: number, reason: string) => void;
166
+ drain: (ws: ServerWebSocket<WsClientData>) => void;
167
+ } {
168
+ return {
169
+ open: (ws) => this.handleOpen(ws),
170
+ message: (ws, message) => this.handleMessage(ws, message),
171
+ close: (ws, code, reason) => this.handleClose(ws, code, reason),
172
+ drain: (ws) => this.handleDrain(ws),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Handle WebSocket upgrade request
178
+ */
179
+ async handleUpgrade(
180
+ req: Request,
181
+ server: Server,
182
+ ): Promise<Response | undefined> {
183
+ const url = new URL(req.url);
184
+ const path = url.pathname;
185
+
186
+ // Find matching gateway
187
+ const gateway = this.getGatewayForPath(path);
188
+ if (!gateway) {
189
+ return new Response('Not Found', { status: 404 });
190
+ }
191
+
192
+ // Extract auth from query or headers
193
+ const token = url.searchParams.get('token') ||
194
+ req.headers.get('Authorization')?.replace('Bearer ', '');
195
+
196
+ // Create client ID
197
+ const clientId = crypto.randomUUID();
198
+
199
+ // Create client data
200
+ const clientData: WsClientData = {
201
+ id: clientId,
202
+ rooms: [],
203
+ connectedAt: Date.now(),
204
+ auth: token ? {
205
+ authenticated: false,
206
+ token,
207
+ } : null,
208
+ metadata: {},
209
+ };
210
+
211
+ // Try to upgrade
212
+ const success = server.upgrade(req, {
213
+ data: clientData,
214
+ });
215
+
216
+ if (success) {
217
+ return undefined; // Bun handles the 101 response
218
+ }
219
+
220
+ return new Response('Upgrade failed', { status: 400 });
221
+ }
222
+
223
+ /**
224
+ * Handle new WebSocket connection
225
+ */
226
+ private async handleOpen(ws: ServerWebSocket<WsClientData>): Promise<void> {
227
+ const client = ws.data;
228
+ this.logger.debug(`WebSocket client connected: ${client.id}`);
229
+
230
+ // Store client
231
+ await this.storage.addClient(client);
232
+
233
+ // Register socket in gateway
234
+ for (const [_, gateway] of this.gateways) {
235
+ gateway.instance._registerSocket(client.id, ws);
236
+ }
237
+
238
+ // Send Socket.IO handshake
239
+ const handshake = createHandshake(client.id, {
240
+ pingInterval: this.pingIntervalMs,
241
+ pingTimeout: this.pingTimeoutMs,
242
+ maxPayload: this.maxPayload,
243
+ });
244
+ ws.send(createOpenPacket(handshake));
245
+
246
+ // Start ping interval
247
+ this.startPingInterval(client.id, ws);
248
+
249
+ // Call OnConnect handlers
250
+ for (const [_, gateway] of this.gateways) {
251
+ const handlers = gateway.handlers.get(HandlerType.CONNECT) || [];
252
+ for (const handler of handlers) {
253
+ try {
254
+ const result = await this.executeHandler(gateway, handler, ws, undefined, {});
255
+ if (result && isWsHandlerResponse(result)) {
256
+ ws.send(createNativeMessage(result.event, result.data));
257
+ }
258
+ } catch (error) {
259
+ this.logger.error(`Error in OnConnect handler: ${error}`);
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Handle incoming message
267
+ */
268
+ private async handleMessage(
269
+ ws: ServerWebSocket<WsClientData>,
270
+ message: string | Buffer,
271
+ ): Promise<void> {
272
+ const messageStr = typeof message === 'string' ? message : message.toString();
273
+
274
+ // Try native format first
275
+ if (isNativeMessage(messageStr)) {
276
+ const native = parseNativeMessage(messageStr);
277
+ if (native) {
278
+ await this.routeMessage(ws, native.event, native.data, native.ack);
279
+
280
+ return;
281
+ }
282
+ }
283
+
284
+ // Parse Socket.IO format
285
+ const { engineIO, socketIO } = parseMessage(messageStr);
286
+
287
+ // Handle Engine.IO packets
288
+ switch (engineIO.type) {
289
+ case EngineIOPacketType.PING:
290
+ ws.send(createPongPacket(engineIO.data as string | undefined));
291
+
292
+ return;
293
+
294
+ case EngineIOPacketType.PONG:
295
+ // Client responded to ping - connection is alive
296
+ return;
297
+
298
+ case EngineIOPacketType.CLOSE:
299
+ ws.close(1000, 'Client requested close');
300
+
301
+ return;
302
+
303
+ case EngineIOPacketType.MESSAGE:
304
+ // Socket.IO packet
305
+ if (socketIO) {
306
+ await this.handleSocketIOPacket(ws, socketIO);
307
+ }
308
+
309
+ return;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Handle Socket.IO packet
315
+ */
316
+ private async handleSocketIOPacket(
317
+ ws: ServerWebSocket<WsClientData>,
318
+ packet: { type: number; nsp: string; data?: unknown[]; id?: number },
319
+ ): Promise<void> {
320
+ switch (packet.type) {
321
+ case SocketIOPacketType.CONNECT:
322
+ // Client connecting to namespace - send CONNECT acknowledgement
323
+ ws.send(createFullEventMessage('connect', { sid: ws.data.id }, packet.nsp));
324
+ break;
325
+
326
+ case SocketIOPacketType.DISCONNECT:
327
+ // Client disconnecting from namespace
328
+ break;
329
+
330
+ case SocketIOPacketType.EVENT:
331
+ // Handle event
332
+ if (packet.data && Array.isArray(packet.data) && packet.data.length > 0) {
333
+ const [event, ...args] = packet.data;
334
+ if (typeof event === 'string') {
335
+ await this.routeMessage(ws, event, args[0], packet.id);
336
+ }
337
+ }
338
+ break;
339
+
340
+ case SocketIOPacketType.ACK:
341
+ // Acknowledgement - not implemented yet
342
+ break;
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Route message to appropriate handler
348
+ */
349
+ private async routeMessage(
350
+ ws: ServerWebSocket<WsClientData>,
351
+ event: string,
352
+ data: unknown,
353
+ ackId?: number,
354
+ ): Promise<void> {
355
+ // Check for room join/leave events
356
+ if (event === 'join' || event.startsWith('join:')) {
357
+ await this.handleRoomJoin(ws, event, data, ackId);
358
+
359
+ return;
360
+ }
361
+
362
+ if (event === 'leave' || event.startsWith('leave:')) {
363
+ await this.handleRoomLeave(ws, event, data, ackId);
364
+
365
+ return;
366
+ }
367
+
368
+ // Find matching message handler
369
+ for (const [_, gateway] of this.gateways) {
370
+ const handlers = gateway.handlers.get(HandlerType.MESSAGE) || [];
371
+
372
+ for (const handler of handlers) {
373
+ if (!handler.pattern) {
374
+ continue;
375
+ }
376
+
377
+ const match = matchPattern(handler.pattern, event);
378
+ if (match.matched) {
379
+ try {
380
+ const result = await this.executeHandler(gateway, handler, ws, data, match.params);
381
+
382
+ // Send response
383
+ if (result !== undefined) {
384
+ if (ackId !== undefined) {
385
+ ws.send(createFullAckMessage(ackId, result));
386
+ } else if (isWsHandlerResponse(result)) {
387
+ ws.send(createNativeMessage(result.event, result.data));
388
+ }
389
+ }
390
+ } catch (error) {
391
+ this.logger.error(`Error in message handler: ${error}`);
392
+ }
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Handle room join
400
+ */
401
+ private async handleRoomJoin(
402
+ ws: ServerWebSocket<WsClientData>,
403
+ event: string,
404
+ data: unknown,
405
+ ackId?: number,
406
+ ): Promise<void> {
407
+ const client = ws.data;
408
+ const roomName = typeof data === 'string' ? data :
409
+ (data as { room?: string })?.room ||
410
+ event.replace('join:', '');
411
+
412
+ // Add to storage
413
+ await this.storage.addClientToRoom(client.id, roomName);
414
+
415
+ // Subscribe to Bun pub/sub
416
+ ws.subscribe(roomName);
417
+
418
+ // Update client data
419
+ if (!client.rooms.includes(roomName)) {
420
+ client.rooms.push(roomName);
421
+ }
422
+
423
+ // Call OnJoinRoom handlers
424
+ for (const [_, gateway] of this.gateways) {
425
+ const handlers = gateway.handlers.get(HandlerType.JOIN_ROOM) || [];
426
+
427
+ for (const handler of handlers) {
428
+ const match = handler.pattern ? matchPattern(handler.pattern, roomName) : { matched: true, params: {} };
429
+
430
+ if (match.matched) {
431
+ try {
432
+ const result = await this.executeHandler(gateway, handler, ws, data, match.params, roomName);
433
+ if (result !== undefined) {
434
+ if (ackId !== undefined) {
435
+ ws.send(createFullAckMessage(ackId, result));
436
+ } else if (isWsHandlerResponse(result)) {
437
+ ws.send(createNativeMessage(result.event, result.data));
438
+ }
439
+ }
440
+ } catch (error) {
441
+ this.logger.error(`Error in OnJoinRoom handler: ${error}`);
442
+ }
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Handle room leave
450
+ */
451
+ private async handleRoomLeave(
452
+ ws: ServerWebSocket<WsClientData>,
453
+ event: string,
454
+ data: unknown,
455
+ ackId?: number,
456
+ ): Promise<void> {
457
+ const client = ws.data;
458
+ const roomName = typeof data === 'string' ? data :
459
+ (data as { room?: string })?.room ||
460
+ event.replace('leave:', '');
461
+
462
+ // Remove from storage
463
+ await this.storage.removeClientFromRoom(client.id, roomName);
464
+
465
+ // Unsubscribe from Bun pub/sub
466
+ ws.unsubscribe(roomName);
467
+
468
+ // Update client data
469
+ client.rooms = client.rooms.filter((r) => r !== roomName);
470
+
471
+ // Call OnLeaveRoom handlers
472
+ for (const [_, gateway] of this.gateways) {
473
+ const handlers = gateway.handlers.get(HandlerType.LEAVE_ROOM) || [];
474
+
475
+ for (const handler of handlers) {
476
+ const match = handler.pattern ? matchPattern(handler.pattern, roomName) : { matched: true, params: {} };
477
+
478
+ if (match.matched) {
479
+ try {
480
+ const result = await this.executeHandler(gateway, handler, ws, data, match.params, roomName);
481
+ if (result !== undefined) {
482
+ if (ackId !== undefined) {
483
+ ws.send(createFullAckMessage(ackId, result));
484
+ } else if (isWsHandlerResponse(result)) {
485
+ ws.send(createNativeMessage(result.event, result.data));
486
+ }
487
+ }
488
+ } catch (error) {
489
+ this.logger.error(`Error in OnLeaveRoom handler: ${error}`);
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Execute a handler with parameter injection
498
+ */
499
+ private async executeHandler(
500
+ gateway: GatewayInstance,
501
+ handler: WsHandlerMetadata,
502
+ ws: ServerWebSocket<WsClientData>,
503
+ data: unknown,
504
+ patternParams: Record<string, string>,
505
+ roomName?: string,
506
+ ): Promise<unknown> {
507
+ const client = ws.data;
508
+ const instance = gateway.instance;
509
+
510
+ // Check guards
511
+ if (handler.guards && handler.guards.length > 0) {
512
+ const context = new WsExecutionContextImpl(
513
+ client,
514
+ ws,
515
+ data,
516
+ handler,
517
+ patternParams,
518
+ );
519
+
520
+ const canActivate = await executeGuards(handler.guards, context);
521
+ if (!canActivate) {
522
+ this.logger.debug(`Guard rejected request for ${handler.handler}`);
523
+
524
+ return undefined;
525
+ }
526
+ }
527
+
528
+ // Build arguments
529
+ const params = handler.params || [];
530
+ const sortedParams = [...params].sort((a, b) => a.index - b.index);
531
+ const args: unknown[] = [];
532
+
533
+ for (const param of sortedParams) {
534
+ switch (param.type) {
535
+ case ParamType.CLIENT:
536
+ args[param.index] = client;
537
+ break;
538
+
539
+ case ParamType.SOCKET:
540
+ args[param.index] = ws;
541
+ break;
542
+
543
+ case ParamType.MESSAGE_DATA:
544
+ if (param.property && typeof data === 'object' && data !== null) {
545
+ args[param.index] = (data as Record<string, unknown>)[param.property];
546
+ } else {
547
+ args[param.index] = data;
548
+ }
549
+ break;
550
+
551
+ case ParamType.ROOM_NAME:
552
+ args[param.index] = roomName;
553
+ break;
554
+
555
+ case ParamType.PATTERN_PARAMS:
556
+ args[param.index] = patternParams;
557
+ break;
558
+
559
+ case ParamType.SERVER:
560
+ args[param.index] = gateway.instance.getWsServer();
561
+ break;
562
+ }
563
+ }
564
+
565
+ // Call handler
566
+ const method = (instance as unknown as Record<string, Function>)[handler.handler];
567
+ if (typeof method === 'function') {
568
+ return await method.apply(instance, args);
569
+ }
570
+
571
+ return undefined;
572
+ }
573
+
574
+ /**
575
+ * Handle WebSocket close
576
+ */
577
+ private async handleClose(
578
+ ws: ServerWebSocket<WsClientData>,
579
+ code: number,
580
+ reason: string,
581
+ ): Promise<void> {
582
+ const client = ws.data;
583
+ this.logger.debug(`WebSocket client disconnected: ${client.id} (${code}: ${reason})`);
584
+
585
+ // Stop ping interval
586
+ this.stopPingInterval(client.id);
587
+
588
+ // Call OnDisconnect handlers
589
+ for (const [_, gateway] of this.gateways) {
590
+ const handlers = gateway.handlers.get(HandlerType.DISCONNECT) || [];
591
+ for (const handler of handlers) {
592
+ try {
593
+ await this.executeHandler(gateway, handler, ws, undefined, {});
594
+ } catch (error) {
595
+ this.logger.error(`Error in OnDisconnect handler: ${error}`);
596
+ }
597
+ }
598
+
599
+ // Unregister socket
600
+ gateway.instance._unregisterSocket(client.id);
601
+ }
602
+
603
+ // Remove from storage
604
+ await this.storage.removeClient(client.id);
605
+ }
606
+
607
+ /**
608
+ * Handle WebSocket drain (backpressure)
609
+ */
610
+ private handleDrain(ws: ServerWebSocket<WsClientData>): void {
611
+ this.logger.debug(`WebSocket drain for client: ${ws.data.id}`);
612
+ }
613
+
614
+ /**
615
+ * Start ping interval for client
616
+ */
617
+ private startPingInterval(clientId: string, ws: ServerWebSocket<WsClientData>): void {
618
+ const interval = setInterval(() => {
619
+ try {
620
+ // Send Engine.IO ping
621
+ ws.send(String(EngineIOPacketType.PING));
622
+ } catch {
623
+ this.stopPingInterval(clientId);
624
+ }
625
+ }, this.pingIntervalMs);
626
+
627
+ this.pingIntervals.set(clientId, interval);
628
+ }
629
+
630
+ /**
631
+ * Stop ping interval for client
632
+ */
633
+ private stopPingInterval(clientId: string): void {
634
+ const interval = this.pingIntervals.get(clientId);
635
+ if (interval) {
636
+ clearInterval(interval);
637
+ this.pingIntervals.delete(clientId);
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Cleanup all resources
643
+ */
644
+ async cleanup(): Promise<void> {
645
+ // Stop all ping intervals
646
+ for (const [clientId, _] of this.pingIntervals) {
647
+ this.stopPingInterval(clientId);
648
+ }
649
+
650
+ // Clear storage
651
+ await this.storage.clear();
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Check if a class is a WebSocket gateway (for use in module)
657
+ */
658
+ export { isWebSocketGateway };