@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,302 @@
1
+ /**
2
+ * Redis WebSocket Storage
3
+ *
4
+ * Redis-based implementation of WsStorageAdapter with pub/sub support
5
+ * for multi-instance deployments.
6
+ */
7
+
8
+ import type { RedisClient } from './redis-client';
9
+ import type { WsPubSubStorageAdapter, WsStorageEventPayload } from './ws-storage';
10
+ import type { WsClientData, WsRoom } from './ws.types';
11
+
12
+ import { isPatternMatch } from './ws-pattern-matcher';
13
+
14
+
15
+ /**
16
+ * Redis key prefixes for WebSocket data
17
+ */
18
+ const KEYS = {
19
+ CLIENTS: 'ws:clients:',
20
+ ROOMS: 'ws:rooms:',
21
+ ROOM_MEMBERS: 'ws:room:members:',
22
+ CLIENT_ROOMS: 'ws:client:rooms:',
23
+ PUBSUB_CHANNEL: 'ws:events',
24
+ };
25
+
26
+ /**
27
+ * Redis-based storage for WebSocket clients and rooms
28
+ * with pub/sub support for multi-instance deployments
29
+ */
30
+ export class RedisWsStorage implements WsPubSubStorageAdapter {
31
+ private eventHandlers: Array<(payload: WsStorageEventPayload) => void> = [];
32
+ private subscribed = false;
33
+
34
+ constructor(private redisClient: RedisClient) {}
35
+
36
+ // ============================================================================
37
+ // Client Operations
38
+ // ============================================================================
39
+
40
+ async addClient(client: WsClientData): Promise<void> {
41
+ const key = KEYS.CLIENTS + client.id;
42
+ await this.redisClient.set(key, JSON.stringify(client));
43
+ }
44
+
45
+ async removeClient(clientId: string): Promise<void> {
46
+ // Remove from all rooms first
47
+ await this.removeClientFromAllRooms(clientId);
48
+
49
+ // Remove client data
50
+ await this.redisClient.del(KEYS.CLIENTS + clientId);
51
+ }
52
+
53
+ async getClient(clientId: string): Promise<WsClientData | null> {
54
+ const data = await this.redisClient.get(KEYS.CLIENTS + clientId);
55
+ if (!data) {
56
+ return null;
57
+ }
58
+
59
+ try {
60
+ return JSON.parse(data) as WsClientData;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ async getAllClients(): Promise<WsClientData[]> {
67
+ const keys = await this.redisClient.keys(KEYS.CLIENTS + '*');
68
+ const clients: WsClientData[] = [];
69
+
70
+ for (const key of keys) {
71
+ const clientId = key.replace(KEYS.CLIENTS, '');
72
+ const client = await this.getClient(clientId);
73
+ if (client) {
74
+ clients.push(client);
75
+ }
76
+ }
77
+
78
+ return clients;
79
+ }
80
+
81
+ async updateClient(clientId: string, data: Partial<WsClientData>): Promise<void> {
82
+ const client = await this.getClient(clientId);
83
+ if (client) {
84
+ const updated = { ...client, ...data };
85
+ await this.redisClient.set(KEYS.CLIENTS + clientId, JSON.stringify(updated));
86
+ }
87
+ }
88
+
89
+ async getClientCount(): Promise<number> {
90
+ const keys = await this.redisClient.keys(KEYS.CLIENTS + '*');
91
+
92
+ return keys.length;
93
+ }
94
+
95
+ // ============================================================================
96
+ // Room Operations
97
+ // ============================================================================
98
+
99
+ async createRoom(room: WsRoom): Promise<void> {
100
+ const key = KEYS.ROOMS + room.name;
101
+ await this.redisClient.set(key, JSON.stringify(room));
102
+
103
+ // Also store members in a set for efficient queries
104
+ if (room.clientIds.length > 0) {
105
+ await this.redisClient.sadd(KEYS.ROOM_MEMBERS + room.name, ...room.clientIds);
106
+ }
107
+ }
108
+
109
+ async deleteRoom(name: string): Promise<void> {
110
+ const room = await this.getRoom(name);
111
+ if (!room) {
112
+ return;
113
+ }
114
+
115
+ // Remove room from all clients' room lists
116
+ for (const clientId of room.clientIds) {
117
+ await this.redisClient.srem(KEYS.CLIENT_ROOMS + clientId, name);
118
+ }
119
+
120
+ // Delete room data and members set
121
+ await this.redisClient.del(KEYS.ROOMS + name);
122
+ await this.redisClient.del(KEYS.ROOM_MEMBERS + name);
123
+ }
124
+
125
+ async getRoom(name: string): Promise<WsRoom | null> {
126
+ const data = await this.redisClient.get(KEYS.ROOMS + name);
127
+ if (!data) {
128
+ return null;
129
+ }
130
+
131
+ try {
132
+ const room = JSON.parse(data) as WsRoom;
133
+ // Get current members from set
134
+ room.clientIds = await this.redisClient.smembers(KEYS.ROOM_MEMBERS + name);
135
+
136
+ return room;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ async getAllRooms(): Promise<WsRoom[]> {
143
+ const keys = await this.redisClient.keys(KEYS.ROOMS + '*');
144
+ const rooms: WsRoom[] = [];
145
+
146
+ for (const key of keys) {
147
+ const roomName = key.replace(KEYS.ROOMS, '');
148
+ const room = await this.getRoom(roomName);
149
+ if (room) {
150
+ rooms.push(room);
151
+ }
152
+ }
153
+
154
+ return rooms;
155
+ }
156
+
157
+ async getRoomsByPattern(pattern: string): Promise<WsRoom[]> {
158
+ const allRooms = await this.getAllRooms();
159
+
160
+ return allRooms.filter((room) => isPatternMatch(pattern, room.name));
161
+ }
162
+
163
+ async updateRoomMetadata(name: string, metadata: Record<string, unknown>): Promise<void> {
164
+ const room = await this.getRoom(name);
165
+ if (room) {
166
+ room.metadata = { ...room.metadata, ...metadata };
167
+ await this.redisClient.set(KEYS.ROOMS + name, JSON.stringify(room));
168
+ }
169
+ }
170
+
171
+ // ============================================================================
172
+ // Room Membership Operations
173
+ // ============================================================================
174
+
175
+ async addClientToRoom(clientId: string, roomName: string): Promise<void> {
176
+ // Ensure room exists
177
+ let room = await this.getRoom(roomName);
178
+ if (!room) {
179
+ room = { name: roomName, clientIds: [] };
180
+ await this.redisClient.set(KEYS.ROOMS + roomName, JSON.stringify(room));
181
+ }
182
+
183
+ // Add to room members set
184
+ await this.redisClient.sadd(KEYS.ROOM_MEMBERS + roomName, clientId);
185
+
186
+ // Add to client's rooms set
187
+ await this.redisClient.sadd(KEYS.CLIENT_ROOMS + clientId, roomName);
188
+
189
+ // Update client's room list in client data
190
+ const client = await this.getClient(clientId);
191
+ if (client && !client.rooms.includes(roomName)) {
192
+ client.rooms.push(roomName);
193
+ await this.updateClient(clientId, { rooms: client.rooms });
194
+ }
195
+ }
196
+
197
+ async removeClientFromRoom(clientId: string, roomName: string): Promise<void> {
198
+ // Remove from room members set
199
+ await this.redisClient.srem(KEYS.ROOM_MEMBERS + roomName, clientId);
200
+
201
+ // Remove from client's rooms set
202
+ await this.redisClient.srem(KEYS.CLIENT_ROOMS + clientId, roomName);
203
+
204
+ // Update client's room list in client data
205
+ const client = await this.getClient(clientId);
206
+ if (client) {
207
+ client.rooms = client.rooms.filter((r) => r !== roomName);
208
+ await this.updateClient(clientId, { rooms: client.rooms });
209
+ }
210
+
211
+ // Delete room if empty
212
+ const memberCount = await this.redisClient.scard(KEYS.ROOM_MEMBERS + roomName);
213
+ if (memberCount === 0) {
214
+ await this.redisClient.del(KEYS.ROOMS + roomName);
215
+ await this.redisClient.del(KEYS.ROOM_MEMBERS + roomName);
216
+ }
217
+ }
218
+
219
+ async getClientsInRoom(roomName: string): Promise<string[]> {
220
+ return await this.redisClient.smembers(KEYS.ROOM_MEMBERS + roomName);
221
+ }
222
+
223
+ async getRoomsForClient(clientId: string): Promise<string[]> {
224
+ return await this.redisClient.smembers(KEYS.CLIENT_ROOMS + clientId);
225
+ }
226
+
227
+ async removeClientFromAllRooms(clientId: string): Promise<void> {
228
+ const rooms = await this.getRoomsForClient(clientId);
229
+
230
+ for (const roomName of rooms) {
231
+ await this.removeClientFromRoom(clientId, roomName);
232
+ }
233
+ }
234
+
235
+ // ============================================================================
236
+ // Pub/Sub Operations
237
+ // ============================================================================
238
+
239
+ async subscribe(handler: (payload: WsStorageEventPayload) => void): Promise<void> {
240
+ this.eventHandlers.push(handler);
241
+
242
+ if (!this.subscribed) {
243
+ await this.redisClient.subscribe(KEYS.PUBSUB_CHANNEL, (message: string) => {
244
+ try {
245
+ const payload = JSON.parse(message) as WsStorageEventPayload;
246
+ for (const h of this.eventHandlers) {
247
+ try {
248
+ h(payload);
249
+ } catch {
250
+ // Ignore handler errors
251
+ }
252
+ }
253
+ } catch {
254
+ // Ignore invalid messages
255
+ }
256
+ });
257
+ this.subscribed = true;
258
+ }
259
+ }
260
+
261
+ async publish(payload: WsStorageEventPayload): Promise<void> {
262
+ await this.redisClient.publish(KEYS.PUBSUB_CHANNEL, JSON.stringify(payload));
263
+ }
264
+
265
+ async unsubscribe(): Promise<void> {
266
+ this.eventHandlers = [];
267
+ if (this.subscribed) {
268
+ await this.redisClient.unsubscribe(KEYS.PUBSUB_CHANNEL);
269
+ this.subscribed = false;
270
+ }
271
+ }
272
+
273
+ // ============================================================================
274
+ // Lifecycle Operations
275
+ // ============================================================================
276
+
277
+ async clear(): Promise<void> {
278
+ // Get all keys
279
+ const clientKeys = await this.redisClient.keys(KEYS.CLIENTS + '*');
280
+ const roomKeys = await this.redisClient.keys(KEYS.ROOMS + '*');
281
+ const memberKeys = await this.redisClient.keys(KEYS.ROOM_MEMBERS + '*');
282
+ const clientRoomKeys = await this.redisClient.keys(KEYS.CLIENT_ROOMS + '*');
283
+
284
+ // Delete all keys
285
+ for (const key of [...clientKeys, ...roomKeys, ...memberKeys, ...clientRoomKeys]) {
286
+ await this.redisClient.del(key);
287
+ }
288
+ }
289
+
290
+ async close(): Promise<void> {
291
+ await this.unsubscribe();
292
+ // Note: We don't disconnect the Redis client here
293
+ // because it might be shared with other consumers
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Create Redis WebSocket storage with a Redis client
299
+ */
300
+ export function createRedisWsStorage(redisClient: RedisClient): WsPubSubStorageAdapter {
301
+ return new RedisWsStorage(redisClient);
302
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * WebSocket Storage Interface
3
+ *
4
+ * Defines the contract for storing WebSocket client and room data.
5
+ * Implementations include in-memory storage and Redis storage.
6
+ */
7
+
8
+ import type { WsClientData, WsRoom } from './ws.types';
9
+
10
+ /**
11
+ * Storage adapter interface for WebSocket state management
12
+ */
13
+ export interface WsStorageAdapter {
14
+ // ============================================================================
15
+ // Client Operations
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Add a new client to storage
20
+ * @param client - Client data to store
21
+ */
22
+ addClient(client: WsClientData): Promise<void>;
23
+
24
+ /**
25
+ * Remove a client from storage
26
+ * @param clientId - ID of client to remove
27
+ */
28
+ removeClient(clientId: string): Promise<void>;
29
+
30
+ /**
31
+ * Get a client by ID
32
+ * @param clientId - ID of client to retrieve
33
+ * @returns Client data or null if not found
34
+ */
35
+ getClient(clientId: string): Promise<WsClientData | null>;
36
+
37
+ /**
38
+ * Get all connected clients
39
+ * @returns Array of all client data
40
+ */
41
+ getAllClients(): Promise<WsClientData[]>;
42
+
43
+ /**
44
+ * Update client data
45
+ * @param clientId - ID of client to update
46
+ * @param data - Partial client data to merge
47
+ */
48
+ updateClient(clientId: string, data: Partial<WsClientData>): Promise<void>;
49
+
50
+ /**
51
+ * Get number of connected clients
52
+ * @returns Client count
53
+ */
54
+ getClientCount(): Promise<number>;
55
+
56
+ // ============================================================================
57
+ // Room Operations
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Create a new room
62
+ * @param room - Room data to create
63
+ */
64
+ createRoom(room: WsRoom): Promise<void>;
65
+
66
+ /**
67
+ * Delete a room
68
+ * @param name - Name of room to delete
69
+ */
70
+ deleteRoom(name: string): Promise<void>;
71
+
72
+ /**
73
+ * Get a room by name
74
+ * @param name - Name of room to retrieve
75
+ * @returns Room data or null if not found
76
+ */
77
+ getRoom(name: string): Promise<WsRoom | null>;
78
+
79
+ /**
80
+ * Get all rooms
81
+ * @returns Array of all room data
82
+ */
83
+ getAllRooms(): Promise<WsRoom[]>;
84
+
85
+ /**
86
+ * Get rooms matching a pattern
87
+ * @param pattern - Pattern to match room names
88
+ * @returns Array of matching rooms
89
+ */
90
+ getRoomsByPattern(pattern: string): Promise<WsRoom[]>;
91
+
92
+ /**
93
+ * Update room metadata
94
+ * @param name - Name of room to update
95
+ * @param metadata - Metadata to merge
96
+ */
97
+ updateRoomMetadata(name: string, metadata: Record<string, unknown>): Promise<void>;
98
+
99
+ // ============================================================================
100
+ // Room Membership Operations
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Add a client to a room
105
+ * @param clientId - ID of client to add
106
+ * @param roomName - Name of room to join
107
+ */
108
+ addClientToRoom(clientId: string, roomName: string): Promise<void>;
109
+
110
+ /**
111
+ * Remove a client from a room
112
+ * @param clientId - ID of client to remove
113
+ * @param roomName - Name of room to leave
114
+ */
115
+ removeClientFromRoom(clientId: string, roomName: string): Promise<void>;
116
+
117
+ /**
118
+ * Get all client IDs in a room
119
+ * @param roomName - Name of room
120
+ * @returns Array of client IDs
121
+ */
122
+ getClientsInRoom(roomName: string): Promise<string[]>;
123
+
124
+ /**
125
+ * Get all rooms a client has joined
126
+ * @param clientId - ID of client
127
+ * @returns Array of room names
128
+ */
129
+ getRoomsForClient(clientId: string): Promise<string[]>;
130
+
131
+ /**
132
+ * Remove a client from all rooms
133
+ * @param clientId - ID of client to remove from all rooms
134
+ */
135
+ removeClientFromAllRooms(clientId: string): Promise<void>;
136
+
137
+ // ============================================================================
138
+ // Lifecycle Operations
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Clear all data (clients and rooms)
143
+ */
144
+ clear(): Promise<void>;
145
+
146
+ /**
147
+ * Close storage connections (if any)
148
+ */
149
+ close(): Promise<void>;
150
+ }
151
+
152
+ /**
153
+ * Event types for storage pub/sub (used with Redis)
154
+ */
155
+ export enum WsStorageEvent {
156
+ CLIENT_CONNECTED = 'ws:client:connected',
157
+ CLIENT_DISCONNECTED = 'ws:client:disconnected',
158
+ CLIENT_JOINED_ROOM = 'ws:client:joined',
159
+ CLIENT_LEFT_ROOM = 'ws:client:left',
160
+ BROADCAST = 'ws:broadcast',
161
+ ROOM_BROADCAST = 'ws:room:broadcast',
162
+ CLIENT_MESSAGE = 'ws:client:message',
163
+ }
164
+
165
+ /**
166
+ * Payload for storage events
167
+ */
168
+ export interface WsStorageEventPayload {
169
+ /** Event type */
170
+ type: WsStorageEvent;
171
+ /** Source instance ID (for multi-instance setups) */
172
+ sourceInstanceId: string;
173
+ /** Event-specific data */
174
+ data: {
175
+ clientId?: string;
176
+ roomName?: string;
177
+ event?: string;
178
+ message?: unknown;
179
+ excludeClientIds?: string[];
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Extended storage adapter with pub/sub support (for Redis)
185
+ */
186
+ export interface WsPubSubStorageAdapter extends WsStorageAdapter {
187
+ /**
188
+ * Subscribe to storage events
189
+ * @param handler - Event handler function
190
+ */
191
+ subscribe(handler: (payload: WsStorageEventPayload) => void): Promise<void>;
192
+
193
+ /**
194
+ * Publish an event to all instances
195
+ * @param payload - Event payload
196
+ */
197
+ publish(payload: WsStorageEventPayload): Promise<void>;
198
+
199
+ /**
200
+ * Unsubscribe from storage events
201
+ */
202
+ unsubscribe(): Promise<void>;
203
+ }
204
+
205
+ /**
206
+ * Check if storage adapter supports pub/sub
207
+ */
208
+ export function isPubSubAdapter(adapter: WsStorageAdapter): adapter is WsPubSubStorageAdapter {
209
+ return 'subscribe' in adapter && 'publish' in adapter && 'unsubscribe' in adapter;
210
+ }