@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,514 @@
1
+ /**
2
+ * Base WebSocket Gateway
3
+ *
4
+ * Abstract base class for WebSocket gateways.
5
+ * Provides methods for client/room management and message broadcasting.
6
+ */
7
+
8
+ import type { WsStorageAdapter, WsPubSubStorageAdapter } from './ws-storage';
9
+ import type {
10
+ WsClientData,
11
+ WsRoom,
12
+ WsMessage,
13
+ WsServer,
14
+ } from './ws.types';
15
+ import type { Server, ServerWebSocket } from 'bun';
16
+
17
+ import { WsStorageEvent, isPubSubAdapter } from './ws-storage';
18
+
19
+ /**
20
+ * Map of client IDs to their WebSocket connections
21
+ * Stored separately from storage to keep socket references in memory
22
+ */
23
+ const clientSockets = new Map<string, ServerWebSocket<WsClientData>>();
24
+
25
+ /**
26
+ * Clear all client sockets (for testing purposes only)
27
+ * @internal
28
+ */
29
+ export function _resetClientSocketsForTesting(): void {
30
+ clientSockets.clear();
31
+ }
32
+
33
+ /**
34
+ * Base class for WebSocket gateways
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * @WebSocketGateway({ path: '/ws' })
39
+ * export class ChatGateway extends BaseWebSocketGateway {
40
+ * @OnMessage('chat:message')
41
+ * handleMessage(@Client() client: WsClientData, @MessageData() data: any) {
42
+ * this.broadcast('chat:message', { userId: client.id, ...data });
43
+ * }
44
+ * }
45
+ * ```
46
+ */
47
+ export abstract class BaseWebSocketGateway {
48
+ /** Storage adapter for persisting client/room data */
49
+ protected storage: WsStorageAdapter | null = null;
50
+
51
+ /** Bun server reference */
52
+ protected server: Server | null = null;
53
+
54
+ /** Unique instance ID (for multi-instance setups) */
55
+ protected instanceId: string = crypto.randomUUID();
56
+
57
+ // ============================================================================
58
+ // Initialization
59
+ // ============================================================================
60
+
61
+ /**
62
+ * Initialize the gateway with storage and server
63
+ * Called internally by the framework
64
+ * @internal
65
+ */
66
+ _initialize(storage: WsStorageAdapter, server: Server): void {
67
+ this.storage = storage;
68
+ this.server = server;
69
+
70
+ // Subscribe to pub/sub events if Redis storage
71
+ if (isPubSubAdapter(storage)) {
72
+ this._setupPubSub(storage);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Setup pub/sub for multi-instance support
78
+ * @internal
79
+ */
80
+ private async _setupPubSub(storage: WsPubSubStorageAdapter): Promise<void> {
81
+ await storage.subscribe((payload) => {
82
+ // Ignore events from this instance
83
+ if (payload.sourceInstanceId === this.instanceId) {
84
+ return;
85
+ }
86
+
87
+ // Handle remote events
88
+ switch (payload.type) {
89
+ case WsStorageEvent.BROADCAST:
90
+ // Broadcast to all local clients
91
+ if (payload.data.event && payload.data.message !== undefined) {
92
+ this._localBroadcast(
93
+ payload.data.event,
94
+ payload.data.message,
95
+ payload.data.excludeClientIds,
96
+ );
97
+ }
98
+ break;
99
+
100
+ case WsStorageEvent.ROOM_BROADCAST:
101
+ // Broadcast to room's local clients
102
+ if (payload.data.roomName && payload.data.event && payload.data.message !== undefined) {
103
+ this._localEmitToRoom(
104
+ payload.data.roomName,
105
+ payload.data.event,
106
+ payload.data.message,
107
+ payload.data.excludeClientIds,
108
+ );
109
+ }
110
+ break;
111
+
112
+ case WsStorageEvent.CLIENT_MESSAGE:
113
+ // Send to specific client if local
114
+ if (payload.data.clientId && payload.data.event && payload.data.message !== undefined) {
115
+ this._localEmit(payload.data.clientId, payload.data.event, payload.data.message);
116
+ }
117
+ break;
118
+ }
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Register a client socket
124
+ * @internal
125
+ */
126
+ _registerSocket(clientId: string, socket: ServerWebSocket<WsClientData>): void {
127
+ clientSockets.set(clientId, socket);
128
+ }
129
+
130
+ /**
131
+ * Unregister a client socket
132
+ * @internal
133
+ */
134
+ _unregisterSocket(clientId: string): void {
135
+ clientSockets.delete(clientId);
136
+ }
137
+
138
+ /**
139
+ * Get socket for a client
140
+ * @internal
141
+ */
142
+ protected getSocket(clientId: string): ServerWebSocket<WsClientData> | undefined {
143
+ return clientSockets.get(clientId);
144
+ }
145
+
146
+ // ============================================================================
147
+ // Client Getters
148
+ // ============================================================================
149
+
150
+ /**
151
+ * Get all connected clients
152
+ */
153
+ get clients(): Map<string, WsClientData> {
154
+ // Return a map view from storage - this is async but we need sync access
155
+ // For sync access, maintain a local cache
156
+ const result = new Map<string, WsClientData>();
157
+ // Note: This returns only locally connected clients
158
+ for (const [id, socket] of clientSockets) {
159
+ if (socket.data) {
160
+ result.set(id, socket.data);
161
+ }
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Get all rooms
169
+ */
170
+ get rooms(): Map<string, WsRoom> {
171
+ // Note: For async storage, use getRoomsAsync()
172
+ return new Map();
173
+ }
174
+
175
+ /**
176
+ * Get a client by ID
177
+ */
178
+ async getClient(clientId: string): Promise<WsClientData | undefined> {
179
+ if (!this.storage) {
180
+ return undefined;
181
+ }
182
+ const client = await this.storage.getClient(clientId);
183
+
184
+ return client || undefined;
185
+ }
186
+
187
+ /**
188
+ * Get a room by name
189
+ */
190
+ async getRoom(roomName: string): Promise<WsRoom | undefined> {
191
+ if (!this.storage) {
192
+ return undefined;
193
+ }
194
+ const room = await this.storage.getRoom(roomName);
195
+
196
+ return room || undefined;
197
+ }
198
+
199
+ /**
200
+ * Get all clients in a room
201
+ */
202
+ async getClientsByRoom(roomName: string): Promise<WsClientData[]> {
203
+ if (!this.storage) {
204
+ return [];
205
+ }
206
+ const clientIds = await this.storage.getClientsInRoom(roomName);
207
+ const clients: WsClientData[] = [];
208
+
209
+ for (const id of clientIds) {
210
+ const client = await this.storage.getClient(id);
211
+ if (client) {
212
+ clients.push(client);
213
+ }
214
+ }
215
+
216
+ return clients;
217
+ }
218
+
219
+ /**
220
+ * Get all rooms matching a pattern
221
+ */
222
+ async getRoomsByPattern(pattern: string): Promise<WsRoom[]> {
223
+ if (!this.storage) {
224
+ return [];
225
+ }
226
+
227
+ return await this.storage.getRoomsByPattern(pattern);
228
+ }
229
+
230
+ // ============================================================================
231
+ // Emit Methods
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Send a message to a specific client
236
+ */
237
+ emit(clientId: string, event: string, data: unknown): void {
238
+ this._localEmit(clientId, event, data);
239
+
240
+ // If using Redis, also publish for other instances
241
+ if (this.storage && isPubSubAdapter(this.storage)) {
242
+ this.storage.publish({
243
+ type: WsStorageEvent.CLIENT_MESSAGE,
244
+ sourceInstanceId: this.instanceId,
245
+ data: { clientId, event, message: data },
246
+ });
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Send to local client only
252
+ * @internal
253
+ */
254
+ private _localEmit(clientId: string, event: string, data: unknown): void {
255
+ const socket = clientSockets.get(clientId);
256
+ if (socket) {
257
+ const message: WsMessage = { event, data };
258
+ socket.send(JSON.stringify(message));
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Broadcast a message to all connected clients
264
+ */
265
+ broadcast(event: string, data: unknown, excludeClientIds?: string[]): void {
266
+ this._localBroadcast(event, data, excludeClientIds);
267
+
268
+ // If using Redis, also publish for other instances
269
+ if (this.storage && isPubSubAdapter(this.storage)) {
270
+ this.storage.publish({
271
+ type: WsStorageEvent.BROADCAST,
272
+ sourceInstanceId: this.instanceId,
273
+ data: { event, message: data, excludeClientIds },
274
+ });
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Broadcast to local clients only
280
+ * @internal
281
+ */
282
+ private _localBroadcast(event: string, data: unknown, excludeClientIds?: string[]): void {
283
+ const message = JSON.stringify({ event, data } as WsMessage);
284
+ const excludeSet = new Set(excludeClientIds || []);
285
+
286
+ for (const [clientId, socket] of clientSockets) {
287
+ if (!excludeSet.has(clientId)) {
288
+ socket.send(message);
289
+ }
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Send a message to all clients in a room
295
+ */
296
+ emitToRoom(roomName: string, event: string, data: unknown, excludeClientIds?: string[]): void {
297
+ this._localEmitToRoom(roomName, event, data, excludeClientIds);
298
+
299
+ // If using Redis, also publish for other instances
300
+ if (this.storage && isPubSubAdapter(this.storage)) {
301
+ this.storage.publish({
302
+ type: WsStorageEvent.ROOM_BROADCAST,
303
+ sourceInstanceId: this.instanceId,
304
+ data: {
305
+ roomName, event, message: data, excludeClientIds,
306
+ },
307
+ });
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Emit to room's local clients only
313
+ * @internal
314
+ */
315
+ private async _localEmitToRoom(
316
+ roomName: string,
317
+ event: string,
318
+ data: unknown,
319
+ excludeClientIds?: string[],
320
+ ): Promise<void> {
321
+ if (!this.storage) {
322
+ return;
323
+ }
324
+
325
+ const clientIds = await this.storage.getClientsInRoom(roomName);
326
+ const message = JSON.stringify({ event, data } as WsMessage);
327
+ const excludeSet = new Set(excludeClientIds || []);
328
+
329
+ for (const clientId of clientIds) {
330
+ if (!excludeSet.has(clientId)) {
331
+ const socket = clientSockets.get(clientId);
332
+ if (socket) {
333
+ socket.send(message);
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Send a message to all clients in multiple rooms
341
+ */
342
+ async emitToRooms(
343
+ roomNames: string[],
344
+ event: string,
345
+ data: unknown,
346
+ excludeClientIds?: string[],
347
+ ): Promise<void> {
348
+ // Collect unique client IDs from all rooms
349
+ const clientIdsSet = new Set<string>();
350
+
351
+ for (const roomName of roomNames) {
352
+ if (this.storage) {
353
+ const ids = await this.storage.getClientsInRoom(roomName);
354
+ ids.forEach((id) => clientIdsSet.add(id));
355
+ }
356
+ }
357
+
358
+ // Send to each unique client
359
+ const message = JSON.stringify({ event, data } as WsMessage);
360
+ const excludeSet = new Set(excludeClientIds || []);
361
+
362
+ for (const clientId of clientIdsSet) {
363
+ if (!excludeSet.has(clientId)) {
364
+ const socket = clientSockets.get(clientId);
365
+ if (socket) {
366
+ socket.send(message);
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Send a message to all clients in rooms matching a pattern
374
+ */
375
+ async emitToRoomPattern(
376
+ pattern: string,
377
+ event: string,
378
+ data: unknown,
379
+ excludeClientIds?: string[],
380
+ ): Promise<void> {
381
+ const rooms = await this.getRoomsByPattern(pattern);
382
+ const roomNames = rooms.map((r) => r.name);
383
+ await this.emitToRooms(roomNames, event, data, excludeClientIds);
384
+ }
385
+
386
+ // ============================================================================
387
+ // Connection Management
388
+ // ============================================================================
389
+
390
+ /**
391
+ * Disconnect a specific client
392
+ */
393
+ disconnectClient(clientId: string, reason?: string): void {
394
+ const socket = clientSockets.get(clientId);
395
+ if (socket) {
396
+ socket.close(1000, reason || 'Disconnected by server');
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Disconnect all clients
402
+ */
403
+ disconnectAll(reason?: string): void {
404
+ for (const [_, socket] of clientSockets) {
405
+ socket.close(1000, reason || 'Server shutdown');
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Disconnect all clients in a room
411
+ */
412
+ async disconnectRoom(roomName: string, reason?: string): Promise<void> {
413
+ if (!this.storage) {
414
+ return;
415
+ }
416
+
417
+ const clientIds = await this.storage.getClientsInRoom(roomName);
418
+ for (const clientId of clientIds) {
419
+ this.disconnectClient(clientId, reason);
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Disconnect all clients in rooms matching a pattern
425
+ */
426
+ async disconnectRoomPattern(pattern: string, reason?: string): Promise<void> {
427
+ const rooms = await this.getRoomsByPattern(pattern);
428
+ for (const room of rooms) {
429
+ await this.disconnectRoom(room.name, reason);
430
+ }
431
+ }
432
+
433
+ // ============================================================================
434
+ // Room Management
435
+ // ============================================================================
436
+
437
+ /**
438
+ * Add a client to a room
439
+ */
440
+ async joinRoom(clientId: string, roomName: string): Promise<void> {
441
+ if (!this.storage) {
442
+ return;
443
+ }
444
+ await this.storage.addClientToRoom(clientId, roomName);
445
+
446
+ // Also subscribe to Bun's native pub/sub topic
447
+ const socket = clientSockets.get(clientId);
448
+ if (socket) {
449
+ socket.subscribe(roomName);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Remove a client from a room
455
+ */
456
+ async leaveRoom(clientId: string, roomName: string): Promise<void> {
457
+ if (!this.storage) {
458
+ return;
459
+ }
460
+ await this.storage.removeClientFromRoom(clientId, roomName);
461
+
462
+ // Also unsubscribe from Bun's native pub/sub topic
463
+ const socket = clientSockets.get(clientId);
464
+ if (socket) {
465
+ socket.unsubscribe(roomName);
466
+ }
467
+ }
468
+
469
+ // ============================================================================
470
+ // Lifecycle Hooks (can be overridden)
471
+ // ============================================================================
472
+
473
+ /**
474
+ * Called when a client connects
475
+ * Override in subclass to handle connection events
476
+ */
477
+ protected onClientConnected(_client: WsClientData): void | Promise<void> {
478
+ // Default: no-op
479
+ }
480
+
481
+ /**
482
+ * Called when a client disconnects
483
+ * Override in subclass to handle disconnection events
484
+ */
485
+ protected onClientDisconnected(_client: WsClientData): void | Promise<void> {
486
+ // Default: no-op
487
+ }
488
+
489
+ // ============================================================================
490
+ // Server Access
491
+ // ============================================================================
492
+
493
+ /**
494
+ * Get WebSocket server wrapper
495
+ */
496
+ getWsServer(): WsServer | null {
497
+ if (!this.server) {
498
+ return null;
499
+ }
500
+
501
+ const server = this.server;
502
+
503
+ return {
504
+ server,
505
+ publish(topic: string, message: string | Buffer) {
506
+ server.publish(topic, message);
507
+ },
508
+ subscriberCount(_topic: string) {
509
+ // Bun doesn't expose this directly, so we count from our storage
510
+ return 0; // TODO: implement proper counting
511
+ },
512
+ };
513
+ }
514
+ }