@buenojs/bueno 0.8.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.
- package/.env.example +109 -0
- package/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/architecture.md +652 -0
- package/bun.lock +70 -0
- package/dist/cli/index.js +3233 -0
- package/dist/index.js +9014 -0
- package/package.json +77 -0
- package/src/cache/index.ts +795 -0
- package/src/cli/ARCHITECTURE.md +837 -0
- package/src/cli/bin.ts +10 -0
- package/src/cli/commands/build.ts +425 -0
- package/src/cli/commands/dev.ts +248 -0
- package/src/cli/commands/generate.ts +541 -0
- package/src/cli/commands/help.ts +55 -0
- package/src/cli/commands/index.ts +112 -0
- package/src/cli/commands/migration.ts +355 -0
- package/src/cli/commands/new.ts +804 -0
- package/src/cli/commands/start.ts +208 -0
- package/src/cli/core/args.ts +283 -0
- package/src/cli/core/console.ts +349 -0
- package/src/cli/core/index.ts +60 -0
- package/src/cli/core/prompt.ts +424 -0
- package/src/cli/core/spinner.ts +265 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/templates/deploy.ts +295 -0
- package/src/cli/templates/docker.ts +307 -0
- package/src/cli/templates/index.ts +24 -0
- package/src/cli/utils/fs.ts +428 -0
- package/src/cli/utils/index.ts +8 -0
- package/src/cli/utils/strings.ts +197 -0
- package/src/config/env.ts +408 -0
- package/src/config/index.ts +506 -0
- package/src/config/loader.ts +329 -0
- package/src/config/merge.ts +285 -0
- package/src/config/types.ts +320 -0
- package/src/config/validation.ts +441 -0
- package/src/container/forward-ref.ts +143 -0
- package/src/container/index.ts +386 -0
- package/src/context/index.ts +360 -0
- package/src/database/index.ts +1142 -0
- package/src/database/migrations/index.ts +371 -0
- package/src/database/schema/index.ts +619 -0
- package/src/frontend/api-routes.ts +640 -0
- package/src/frontend/bundler.ts +643 -0
- package/src/frontend/console-client.ts +419 -0
- package/src/frontend/console-stream.ts +587 -0
- package/src/frontend/dev-server.ts +846 -0
- package/src/frontend/file-router.ts +611 -0
- package/src/frontend/frameworks/index.ts +106 -0
- package/src/frontend/frameworks/react.ts +85 -0
- package/src/frontend/frameworks/solid.ts +104 -0
- package/src/frontend/frameworks/svelte.ts +110 -0
- package/src/frontend/frameworks/vue.ts +92 -0
- package/src/frontend/hmr-client.ts +663 -0
- package/src/frontend/hmr.ts +728 -0
- package/src/frontend/index.ts +342 -0
- package/src/frontend/islands.ts +552 -0
- package/src/frontend/isr.ts +555 -0
- package/src/frontend/layout.ts +475 -0
- package/src/frontend/ssr/react.ts +446 -0
- package/src/frontend/ssr/solid.ts +523 -0
- package/src/frontend/ssr/svelte.ts +546 -0
- package/src/frontend/ssr/vue.ts +504 -0
- package/src/frontend/ssr.ts +699 -0
- package/src/frontend/types.ts +2274 -0
- package/src/health/index.ts +604 -0
- package/src/index.ts +410 -0
- package/src/lock/index.ts +587 -0
- package/src/logger/index.ts +444 -0
- package/src/logger/transports/index.ts +969 -0
- package/src/metrics/index.ts +494 -0
- package/src/middleware/built-in.ts +360 -0
- package/src/middleware/index.ts +94 -0
- package/src/modules/filters.ts +458 -0
- package/src/modules/guards.ts +405 -0
- package/src/modules/index.ts +1256 -0
- package/src/modules/interceptors.ts +574 -0
- package/src/modules/lazy.ts +418 -0
- package/src/modules/lifecycle.ts +478 -0
- package/src/modules/metadata.ts +90 -0
- package/src/modules/pipes.ts +626 -0
- package/src/router/index.ts +339 -0
- package/src/router/linear.ts +371 -0
- package/src/router/regex.ts +292 -0
- package/src/router/tree.ts +562 -0
- package/src/rpc/index.ts +1263 -0
- package/src/security/index.ts +436 -0
- package/src/ssg/index.ts +631 -0
- package/src/storage/index.ts +456 -0
- package/src/telemetry/index.ts +1097 -0
- package/src/testing/index.ts +1586 -0
- package/src/types/index.ts +236 -0
- package/src/types/optional-deps.d.ts +219 -0
- package/src/validation/index.ts +276 -0
- package/src/websocket/index.ts +1004 -0
- package/tests/integration/cli.test.ts +1016 -0
- package/tests/integration/fullstack.test.ts +234 -0
- package/tests/unit/cache.test.ts +174 -0
- package/tests/unit/cli-commands.test.ts +892 -0
- package/tests/unit/cli.test.ts +1258 -0
- package/tests/unit/container.test.ts +279 -0
- package/tests/unit/context.test.ts +221 -0
- package/tests/unit/database.test.ts +183 -0
- package/tests/unit/linear-router.test.ts +280 -0
- package/tests/unit/lock.test.ts +336 -0
- package/tests/unit/middleware.test.ts +184 -0
- package/tests/unit/modules.test.ts +142 -0
- package/tests/unit/pubsub.test.ts +257 -0
- package/tests/unit/regex-router.test.ts +265 -0
- package/tests/unit/router.test.ts +373 -0
- package/tests/unit/rpc.test.ts +1248 -0
- package/tests/unit/security.test.ts +174 -0
- package/tests/unit/telemetry.test.ts +371 -0
- package/tests/unit/test-cache.test.ts +110 -0
- package/tests/unit/test-database.test.ts +282 -0
- package/tests/unit/tree-router.test.ts +325 -0
- package/tests/unit/validation.test.ts +794 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Helpers
|
|
3
|
+
*
|
|
4
|
+
* Utilities for WebSocket connections, pub/sub patterns,
|
|
5
|
+
* and real-time communication in Bueno applications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============= Types =============
|
|
9
|
+
|
|
10
|
+
export interface WebSocketData {
|
|
11
|
+
id: string;
|
|
12
|
+
userId?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WebSocketMessage {
|
|
17
|
+
type: string;
|
|
18
|
+
payload: unknown;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WebSocketOptions {
|
|
23
|
+
idleTimeout?: number;
|
|
24
|
+
maxPayloadLength?: number;
|
|
25
|
+
perMessageDeflate?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type WebSocketHandler = (
|
|
29
|
+
ws: Bun.ServerWebSocket<WebSocketData>,
|
|
30
|
+
message: WebSocketMessage,
|
|
31
|
+
) => void | Promise<void>;
|
|
32
|
+
export type OpenHandler = (
|
|
33
|
+
ws: Bun.ServerWebSocket<WebSocketData>,
|
|
34
|
+
) => void | Promise<void>;
|
|
35
|
+
export type CloseHandler = (
|
|
36
|
+
ws: Bun.ServerWebSocket<WebSocketData>,
|
|
37
|
+
code: number,
|
|
38
|
+
reason: string,
|
|
39
|
+
) => void | Promise<void>;
|
|
40
|
+
export type ErrorHandler = (
|
|
41
|
+
ws: Bun.ServerWebSocket<WebSocketData>,
|
|
42
|
+
error: Error,
|
|
43
|
+
) => void | Promise<void>;
|
|
44
|
+
|
|
45
|
+
// ============= WebSocket Server =============
|
|
46
|
+
|
|
47
|
+
export interface WebSocketServerOptions extends WebSocketOptions {
|
|
48
|
+
onMessage?: WebSocketHandler;
|
|
49
|
+
onOpen?: OpenHandler;
|
|
50
|
+
onClose?: CloseHandler;
|
|
51
|
+
onError?: ErrorHandler;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class WebSocketServer {
|
|
55
|
+
private options: WebSocketServerOptions;
|
|
56
|
+
private connections: Map<string, Bun.ServerWebSocket<WebSocketData>> =
|
|
57
|
+
new Map();
|
|
58
|
+
private rooms: Map<string, Set<string>> = new Map();
|
|
59
|
+
private messageHandlers: Map<string, WebSocketHandler[]> = new Map();
|
|
60
|
+
|
|
61
|
+
constructor(options: WebSocketServerOptions = {}) {
|
|
62
|
+
this.options = {
|
|
63
|
+
idleTimeout: 255,
|
|
64
|
+
maxPayloadLength: 1024 * 1024, // 1MB
|
|
65
|
+
perMessageDeflate: true,
|
|
66
|
+
...options,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get Bun WebSocket configuration
|
|
72
|
+
*/
|
|
73
|
+
getWebSocketConfig(): Bun.WebSocketHandler<WebSocketData> {
|
|
74
|
+
return {
|
|
75
|
+
idleTimeout: this.options.idleTimeout,
|
|
76
|
+
maxPayloadLength: this.options.maxPayloadLength,
|
|
77
|
+
perMessageDeflate: this.options.perMessageDeflate,
|
|
78
|
+
|
|
79
|
+
open: (ws) => {
|
|
80
|
+
this.connections.set(ws.data.id, ws);
|
|
81
|
+
this.options.onOpen?.(ws);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
message: (ws, message) => {
|
|
85
|
+
let parsed: WebSocketMessage;
|
|
86
|
+
|
|
87
|
+
if (typeof message === "string") {
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(message);
|
|
90
|
+
} catch {
|
|
91
|
+
parsed = { type: "raw", payload: message, timestamp: Date.now() };
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
parsed = { type: "binary", payload: message, timestamp: Date.now() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Call global handler
|
|
98
|
+
this.options.onMessage?.(ws, parsed);
|
|
99
|
+
|
|
100
|
+
// Call type-specific handlers
|
|
101
|
+
const handlers = this.messageHandlers.get(parsed.type);
|
|
102
|
+
if (handlers) {
|
|
103
|
+
for (const handler of handlers) {
|
|
104
|
+
handler(ws, parsed);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
close: (ws, code, reason) => {
|
|
110
|
+
this.connections.delete(ws.data.id);
|
|
111
|
+
|
|
112
|
+
// Remove from all rooms
|
|
113
|
+
for (const [room, members] of this.rooms) {
|
|
114
|
+
members.delete(ws.data.id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.options.onClose?.(ws, code, reason);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Register a message type handler
|
|
124
|
+
*/
|
|
125
|
+
on(type: string, handler: WebSocketHandler): this {
|
|
126
|
+
const handlers = this.messageHandlers.get(type) ?? [];
|
|
127
|
+
handlers.push(handler);
|
|
128
|
+
this.messageHandlers.set(type, handlers);
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Broadcast to all connections
|
|
134
|
+
*/
|
|
135
|
+
broadcast(type: string, payload: unknown): void {
|
|
136
|
+
const message: WebSocketMessage = { type, payload, timestamp: Date.now() };
|
|
137
|
+
const data = JSON.stringify(message);
|
|
138
|
+
|
|
139
|
+
for (const ws of this.connections.values()) {
|
|
140
|
+
ws.send(data);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Send to specific connection
|
|
146
|
+
*/
|
|
147
|
+
send(connectionId: string, type: string, payload: unknown): boolean {
|
|
148
|
+
const ws = this.connections.get(connectionId);
|
|
149
|
+
if (!ws) return false;
|
|
150
|
+
|
|
151
|
+
const message: WebSocketMessage = { type, payload, timestamp: Date.now() };
|
|
152
|
+
ws.send(JSON.stringify(message));
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Broadcast to a room
|
|
158
|
+
*/
|
|
159
|
+
broadcastToRoom(room: string, type: string, payload: unknown): void {
|
|
160
|
+
const members = this.rooms.get(room);
|
|
161
|
+
if (!members) return;
|
|
162
|
+
|
|
163
|
+
const message: WebSocketMessage = { type, payload, timestamp: Date.now() };
|
|
164
|
+
const data = JSON.stringify(message);
|
|
165
|
+
|
|
166
|
+
for (const id of members) {
|
|
167
|
+
const ws = this.connections.get(id);
|
|
168
|
+
if (ws) {
|
|
169
|
+
ws.send(data);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Join a room
|
|
176
|
+
*/
|
|
177
|
+
joinRoom(connectionId: string, room: string): void {
|
|
178
|
+
if (!this.rooms.has(room)) {
|
|
179
|
+
this.rooms.set(room, new Set());
|
|
180
|
+
}
|
|
181
|
+
this.rooms.get(room)?.add(connectionId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Leave a room
|
|
186
|
+
*/
|
|
187
|
+
leaveRoom(connectionId: string, room: string): void {
|
|
188
|
+
this.rooms.get(room)?.delete(connectionId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get room members
|
|
193
|
+
*/
|
|
194
|
+
getRoomMembers(room: string): string[] {
|
|
195
|
+
return Array.from(this.rooms.get(room) ?? []);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get all connection IDs
|
|
200
|
+
*/
|
|
201
|
+
getConnectionIds(): string[] {
|
|
202
|
+
return Array.from(this.connections.keys());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get connection count
|
|
207
|
+
*/
|
|
208
|
+
get connectionCount(): number {
|
|
209
|
+
return this.connections.size;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Check if connection exists
|
|
214
|
+
*/
|
|
215
|
+
hasConnection(connectionId: string): boolean {
|
|
216
|
+
return this.connections.has(connectionId);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Close a connection
|
|
221
|
+
*/
|
|
222
|
+
closeConnection(
|
|
223
|
+
connectionId: string,
|
|
224
|
+
code = 1000,
|
|
225
|
+
reason = "Closed by server",
|
|
226
|
+
): boolean {
|
|
227
|
+
const ws = this.connections.get(connectionId);
|
|
228
|
+
if (!ws) return false;
|
|
229
|
+
|
|
230
|
+
ws.close(code, reason);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Close all connections
|
|
236
|
+
*/
|
|
237
|
+
closeAll(code = 1000, reason = "Server shutting down"): void {
|
|
238
|
+
for (const ws of this.connections.values()) {
|
|
239
|
+
ws.close(code, reason);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ============= WebSocket Client =============
|
|
245
|
+
|
|
246
|
+
export interface WebSocketClientOptions {
|
|
247
|
+
url: string;
|
|
248
|
+
protocols?: string | string[];
|
|
249
|
+
reconnect?: boolean;
|
|
250
|
+
reconnectInterval?: number;
|
|
251
|
+
maxReconnectAttempts?: number;
|
|
252
|
+
onOpen?: () => void;
|
|
253
|
+
onClose?: (event: CloseEvent) => void;
|
|
254
|
+
onMessage?: (message: WebSocketMessage) => void;
|
|
255
|
+
onError?: (event: Event) => void;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export class WebSocketClient {
|
|
259
|
+
private ws: WebSocket | null = null;
|
|
260
|
+
private options: WebSocketClientOptions;
|
|
261
|
+
private reconnectAttempts = 0;
|
|
262
|
+
private shouldReconnect = true;
|
|
263
|
+
private messageQueue: string[] = [];
|
|
264
|
+
|
|
265
|
+
constructor(options: WebSocketClientOptions) {
|
|
266
|
+
this.options = {
|
|
267
|
+
reconnect: true,
|
|
268
|
+
reconnectInterval: 1000,
|
|
269
|
+
maxReconnectAttempts: 5,
|
|
270
|
+
...options,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Connect to WebSocket server
|
|
276
|
+
*/
|
|
277
|
+
connect(): Promise<void> {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
try {
|
|
280
|
+
this.ws = new WebSocket(this.options.url, this.options.protocols);
|
|
281
|
+
this.shouldReconnect = true;
|
|
282
|
+
|
|
283
|
+
this.ws.onopen = () => {
|
|
284
|
+
this.reconnectAttempts = 0;
|
|
285
|
+
|
|
286
|
+
// Send queued messages
|
|
287
|
+
while (this.messageQueue.length > 0) {
|
|
288
|
+
const message = this.messageQueue.shift()!;
|
|
289
|
+
this.ws?.send(message);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.options.onOpen?.();
|
|
293
|
+
resolve();
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
this.ws.onclose = (event) => {
|
|
297
|
+
this.options.onClose?.(event);
|
|
298
|
+
|
|
299
|
+
if (this.shouldReconnect && this.options.reconnect) {
|
|
300
|
+
this.attemptReconnect();
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
this.ws.onmessage = (event) => {
|
|
305
|
+
let message: WebSocketMessage;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
message = JSON.parse(event.data);
|
|
309
|
+
} catch {
|
|
310
|
+
message = {
|
|
311
|
+
type: "raw",
|
|
312
|
+
payload: event.data,
|
|
313
|
+
timestamp: Date.now(),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.options.onMessage?.(message);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
this.ws.onerror = (event) => {
|
|
321
|
+
this.options.onError?.(event);
|
|
322
|
+
reject(event);
|
|
323
|
+
};
|
|
324
|
+
} catch (error) {
|
|
325
|
+
reject(error);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Attempt to reconnect
|
|
332
|
+
*/
|
|
333
|
+
private attemptReconnect(): void {
|
|
334
|
+
if (this.reconnectAttempts >= (this.options.maxReconnectAttempts ?? 5)) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.reconnectAttempts++;
|
|
339
|
+
setTimeout(() => {
|
|
340
|
+
this.connect().catch(() => {
|
|
341
|
+
// Reconnect failed, will try again if attempts remaining
|
|
342
|
+
});
|
|
343
|
+
}, this.options.reconnectInterval);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Send a message
|
|
348
|
+
*/
|
|
349
|
+
send(type: string, payload: unknown): void {
|
|
350
|
+
const message: WebSocketMessage = { type, payload, timestamp: Date.now() };
|
|
351
|
+
const data = JSON.stringify(message);
|
|
352
|
+
|
|
353
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
354
|
+
this.ws.send(data);
|
|
355
|
+
} else {
|
|
356
|
+
// Queue message for when connection is ready
|
|
357
|
+
this.messageQueue.push(data);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Send raw data
|
|
363
|
+
*/
|
|
364
|
+
async sendRaw(data: string | ArrayBuffer | Blob): Promise<void> {
|
|
365
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
366
|
+
if (data instanceof Blob) {
|
|
367
|
+
this.ws.send(await data.arrayBuffer());
|
|
368
|
+
} else {
|
|
369
|
+
this.ws.send(data);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Close the connection
|
|
376
|
+
*/
|
|
377
|
+
close(code = 1000, reason = "Client closing"): void {
|
|
378
|
+
this.shouldReconnect = false;
|
|
379
|
+
this.ws?.close(code, reason);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Check if connected
|
|
384
|
+
*/
|
|
385
|
+
get isConnected(): boolean {
|
|
386
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Get ready state
|
|
391
|
+
*/
|
|
392
|
+
get readyState(): number {
|
|
393
|
+
return this.ws?.readyState ?? WebSocket.CLOSED;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ============= Pub/Sub Types =============
|
|
398
|
+
|
|
399
|
+
export interface PubSubConfig {
|
|
400
|
+
driver?: "redis" | "memory";
|
|
401
|
+
url?: string; // Redis URL (e.g., redis://localhost:6379)
|
|
402
|
+
keyPrefix?: string;
|
|
403
|
+
reconnect?: boolean;
|
|
404
|
+
reconnectInterval?: number;
|
|
405
|
+
maxReconnectAttempts?: number;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export interface PubSubMessage {
|
|
409
|
+
channel: string;
|
|
410
|
+
pattern?: string; // Present for pattern subscriptions
|
|
411
|
+
data: unknown;
|
|
412
|
+
timestamp: number;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export type PubSubCallback = (message: PubSubMessage) => void | Promise<void>;
|
|
416
|
+
|
|
417
|
+
// ============= In-Memory Pub/Sub (Fallback) =============
|
|
418
|
+
|
|
419
|
+
class InMemoryPubSub {
|
|
420
|
+
private channels: Map<string, Set<PubSubCallback>> = new Map();
|
|
421
|
+
private patterns: Map<string, Set<PubSubCallback>> = new Map();
|
|
422
|
+
|
|
423
|
+
async publish(channel: string, data: unknown): Promise<number> {
|
|
424
|
+
const message: PubSubMessage = {
|
|
425
|
+
channel,
|
|
426
|
+
data,
|
|
427
|
+
timestamp: Date.now(),
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
let deliveredCount = 0;
|
|
431
|
+
|
|
432
|
+
// Direct channel subscribers
|
|
433
|
+
const subscribers = this.channels.get(channel);
|
|
434
|
+
if (subscribers) {
|
|
435
|
+
for (const callback of subscribers) {
|
|
436
|
+
await callback(message);
|
|
437
|
+
deliveredCount++;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Pattern subscribers
|
|
442
|
+
for (const [pattern, callbacks] of this.patterns) {
|
|
443
|
+
if (this.matchPattern(pattern, channel)) {
|
|
444
|
+
const patternMessage = { ...message, pattern };
|
|
445
|
+
for (const callback of callbacks) {
|
|
446
|
+
await callback(patternMessage);
|
|
447
|
+
deliveredCount++;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return deliveredCount;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async subscribe(
|
|
456
|
+
channel: string,
|
|
457
|
+
callback: PubSubCallback,
|
|
458
|
+
): Promise<() => void> {
|
|
459
|
+
if (!this.channels.has(channel)) {
|
|
460
|
+
this.channels.set(channel, new Set());
|
|
461
|
+
}
|
|
462
|
+
this.channels.get(channel)?.add(callback);
|
|
463
|
+
|
|
464
|
+
return () => {
|
|
465
|
+
this.channels.get(channel)?.delete(callback);
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async psubscribe(
|
|
470
|
+
pattern: string,
|
|
471
|
+
callback: PubSubCallback,
|
|
472
|
+
): Promise<() => void> {
|
|
473
|
+
if (!this.patterns.has(pattern)) {
|
|
474
|
+
this.patterns.set(pattern, new Set());
|
|
475
|
+
}
|
|
476
|
+
this.patterns.get(pattern)?.add(callback);
|
|
477
|
+
|
|
478
|
+
return () => {
|
|
479
|
+
this.patterns.get(pattern)?.delete(callback);
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async unsubscribe(channel: string): Promise<void> {
|
|
484
|
+
this.channels.delete(channel);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async punsubscribe(pattern: string): Promise<void> {
|
|
488
|
+
this.patterns.delete(pattern);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
getChannelSubscribers(channel: string): number {
|
|
492
|
+
return this.channels.get(channel)?.size ?? 0;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
getPatternSubscribers(pattern: string): number {
|
|
496
|
+
return this.patterns.get(pattern)?.size ?? 0;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
getTotalSubscribers(): number {
|
|
500
|
+
let total = 0;
|
|
501
|
+
for (const subscribers of this.channels.values()) {
|
|
502
|
+
total += subscribers.size;
|
|
503
|
+
}
|
|
504
|
+
for (const subscribers of this.patterns.values()) {
|
|
505
|
+
total += subscribers.size;
|
|
506
|
+
}
|
|
507
|
+
return total;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async clear(): Promise<void> {
|
|
511
|
+
this.channels.clear();
|
|
512
|
+
this.patterns.clear();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
destroy(): void {
|
|
516
|
+
this.channels.clear();
|
|
517
|
+
this.patterns.clear();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Match a pattern against a channel name
|
|
522
|
+
* Supports * (match any characters) and ? (match single character)
|
|
523
|
+
*/
|
|
524
|
+
private matchPattern(pattern: string, channel: string): boolean {
|
|
525
|
+
const regex = new RegExp(
|
|
526
|
+
`^${pattern
|
|
527
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape special regex chars except * and ?
|
|
528
|
+
.replace(/\*/g, ".*") // * matches any characters
|
|
529
|
+
.replace(/\?/g, ".")}$`,
|
|
530
|
+
);
|
|
531
|
+
return regex.test(channel);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ============= Redis Pub/Sub (Bun.redis Native) =============
|
|
536
|
+
|
|
537
|
+
class RedisPubSub {
|
|
538
|
+
private publisher: unknown = null;
|
|
539
|
+
private subscriber: unknown = null;
|
|
540
|
+
private url: string;
|
|
541
|
+
private keyPrefix: string;
|
|
542
|
+
private channelCallbacks: Map<string, Set<PubSubCallback>> = new Map();
|
|
543
|
+
private patternCallbacks: Map<string, Set<PubSubCallback>> = new Map();
|
|
544
|
+
private _isConnected = false;
|
|
545
|
+
private reconnect: boolean;
|
|
546
|
+
private reconnectInterval: number;
|
|
547
|
+
private maxReconnectAttempts: number;
|
|
548
|
+
private reconnectAttempts = 0;
|
|
549
|
+
|
|
550
|
+
constructor(config: PubSubConfig) {
|
|
551
|
+
this.url = config.url ?? "redis://localhost:6379";
|
|
552
|
+
this.keyPrefix = config.keyPrefix ?? "";
|
|
553
|
+
this.reconnect = config.reconnect ?? true;
|
|
554
|
+
this.reconnectInterval = config.reconnectInterval ?? 1000;
|
|
555
|
+
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async connect(): Promise<void> {
|
|
559
|
+
try {
|
|
560
|
+
// Use Bun's native Redis client
|
|
561
|
+
const { RedisClient } = await import("bun");
|
|
562
|
+
|
|
563
|
+
// Create separate connections for pub and sub
|
|
564
|
+
// (Subscriber connections enter a special mode and can't run other commands)
|
|
565
|
+
this.publisher = new RedisClient(this.url);
|
|
566
|
+
this.subscriber = new RedisClient(this.url);
|
|
567
|
+
this._isConnected = true;
|
|
568
|
+
this.reconnectAttempts = 0;
|
|
569
|
+
} catch (error) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
`Failed to connect to Redis: ${error instanceof Error ? error.message : String(error)}`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async disconnect(): Promise<void> {
|
|
577
|
+
const pub = this.publisher as { close?: () => Promise<void> } | null;
|
|
578
|
+
const sub = this.subscriber as { close?: () => Promise<void> } | null;
|
|
579
|
+
|
|
580
|
+
if (pub?.close) await pub.close();
|
|
581
|
+
if (sub?.close) await sub.close();
|
|
582
|
+
|
|
583
|
+
this._isConnected = false;
|
|
584
|
+
this.publisher = null;
|
|
585
|
+
this.subscriber = null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
get isConnected(): boolean {
|
|
589
|
+
return this._isConnected;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async publish(channel: string, data: unknown): Promise<number> {
|
|
593
|
+
if (!this._isConnected) {
|
|
594
|
+
throw new Error("Redis Pub/Sub not connected");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const fullChannel = this.keyPrefix + channel;
|
|
598
|
+
const message = JSON.stringify({
|
|
599
|
+
channel,
|
|
600
|
+
data,
|
|
601
|
+
timestamp: Date.now(),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const client = this.publisher as {
|
|
605
|
+
publish: (channel: string, message: string) => Promise<number>;
|
|
606
|
+
};
|
|
607
|
+
return client.publish(fullChannel, message);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async subscribe(
|
|
611
|
+
channel: string,
|
|
612
|
+
callback: PubSubCallback,
|
|
613
|
+
): Promise<() => void> {
|
|
614
|
+
if (!this._isConnected) {
|
|
615
|
+
throw new Error("Redis Pub/Sub not connected");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const fullChannel = this.keyPrefix + channel;
|
|
619
|
+
|
|
620
|
+
// Store callback
|
|
621
|
+
if (!this.channelCallbacks.has(channel)) {
|
|
622
|
+
this.channelCallbacks.set(channel, new Set());
|
|
623
|
+
}
|
|
624
|
+
this.channelCallbacks.get(channel)?.add(callback);
|
|
625
|
+
|
|
626
|
+
// Subscribe to Redis channel
|
|
627
|
+
const client = this.subscriber as {
|
|
628
|
+
subscribe: (
|
|
629
|
+
channel: string,
|
|
630
|
+
callback: (message: string, channel: string) => void,
|
|
631
|
+
) => Promise<void>;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// Create wrapper callback for Redis
|
|
635
|
+
const wrappedCallback = (message: string, redisChannel: string) => {
|
|
636
|
+
try {
|
|
637
|
+
const parsed = JSON.parse(message);
|
|
638
|
+
callback({
|
|
639
|
+
channel: parsed.channel ?? channel,
|
|
640
|
+
data: parsed.data,
|
|
641
|
+
timestamp: parsed.timestamp ?? Date.now(),
|
|
642
|
+
});
|
|
643
|
+
} catch {
|
|
644
|
+
// Handle raw string messages
|
|
645
|
+
callback({
|
|
646
|
+
channel,
|
|
647
|
+
data: message,
|
|
648
|
+
timestamp: Date.now(),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
await client.subscribe(fullChannel, wrappedCallback);
|
|
654
|
+
|
|
655
|
+
// Return unsubscribe function
|
|
656
|
+
return async () => {
|
|
657
|
+
this.channelCallbacks.get(channel)?.delete(callback);
|
|
658
|
+
|
|
659
|
+
// If no more callbacks for this channel, unsubscribe from Redis
|
|
660
|
+
if (this.channelCallbacks.get(channel)?.size === 0) {
|
|
661
|
+
this.channelCallbacks.delete(channel);
|
|
662
|
+
const unsubClient = this.subscriber as {
|
|
663
|
+
unsubscribe: (channel: string) => Promise<void>;
|
|
664
|
+
};
|
|
665
|
+
await unsubClient.unsubscribe(fullChannel);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async psubscribe(
|
|
671
|
+
pattern: string,
|
|
672
|
+
callback: PubSubCallback,
|
|
673
|
+
): Promise<() => void> {
|
|
674
|
+
if (!this._isConnected) {
|
|
675
|
+
throw new Error("Redis Pub/Sub not connected");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const fullPattern = this.keyPrefix + pattern;
|
|
679
|
+
|
|
680
|
+
// Store callback
|
|
681
|
+
if (!this.patternCallbacks.has(pattern)) {
|
|
682
|
+
this.patternCallbacks.set(pattern, new Set());
|
|
683
|
+
}
|
|
684
|
+
this.patternCallbacks.get(pattern)?.add(callback);
|
|
685
|
+
|
|
686
|
+
// Subscribe to Redis pattern
|
|
687
|
+
const client = this.subscriber as {
|
|
688
|
+
psubscribe: (
|
|
689
|
+
pattern: string,
|
|
690
|
+
callback: (message: string, channel: string, pattern: string) => void,
|
|
691
|
+
) => Promise<void>;
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
// Create wrapper callback for Redis
|
|
695
|
+
const wrappedCallback = (
|
|
696
|
+
message: string,
|
|
697
|
+
redisChannel: string,
|
|
698
|
+
redisPattern: string,
|
|
699
|
+
) => {
|
|
700
|
+
try {
|
|
701
|
+
const parsed = JSON.parse(message);
|
|
702
|
+
callback({
|
|
703
|
+
channel: parsed.channel ?? redisChannel.replace(this.keyPrefix, ""),
|
|
704
|
+
pattern: pattern,
|
|
705
|
+
data: parsed.data,
|
|
706
|
+
timestamp: parsed.timestamp ?? Date.now(),
|
|
707
|
+
});
|
|
708
|
+
} catch {
|
|
709
|
+
callback({
|
|
710
|
+
channel: redisChannel.replace(this.keyPrefix, ""),
|
|
711
|
+
pattern: pattern,
|
|
712
|
+
data: message,
|
|
713
|
+
timestamp: Date.now(),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
await client.psubscribe(fullPattern, wrappedCallback);
|
|
719
|
+
|
|
720
|
+
// Return unsubscribe function
|
|
721
|
+
return async () => {
|
|
722
|
+
this.patternCallbacks.get(pattern)?.delete(callback);
|
|
723
|
+
|
|
724
|
+
// If no more callbacks for this pattern, unsubscribe from Redis
|
|
725
|
+
if (this.patternCallbacks.get(pattern)?.size === 0) {
|
|
726
|
+
this.patternCallbacks.delete(pattern);
|
|
727
|
+
const unsubClient = this.subscriber as {
|
|
728
|
+
punsubscribe: (pattern: string) => Promise<void>;
|
|
729
|
+
};
|
|
730
|
+
await unsubClient.punsubscribe(fullPattern);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async unsubscribe(channel: string): Promise<void> {
|
|
736
|
+
if (!this._isConnected) return;
|
|
737
|
+
|
|
738
|
+
const fullChannel = this.keyPrefix + channel;
|
|
739
|
+
this.channelCallbacks.delete(channel);
|
|
740
|
+
|
|
741
|
+
const client = this.subscriber as {
|
|
742
|
+
unsubscribe: (channel: string) => Promise<void>;
|
|
743
|
+
};
|
|
744
|
+
await client.unsubscribe(fullChannel);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async punsubscribe(pattern: string): Promise<void> {
|
|
748
|
+
if (!this._isConnected) return;
|
|
749
|
+
|
|
750
|
+
const fullPattern = this.keyPrefix + pattern;
|
|
751
|
+
this.patternCallbacks.delete(pattern);
|
|
752
|
+
|
|
753
|
+
const client = this.subscriber as {
|
|
754
|
+
punsubscribe: (pattern: string) => Promise<void>;
|
|
755
|
+
};
|
|
756
|
+
await client.punsubscribe(fullPattern);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
getChannelSubscribers(channel: string): number {
|
|
760
|
+
return this.channelCallbacks.get(channel)?.size ?? 0;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
getPatternSubscribers(pattern: string): number {
|
|
764
|
+
return this.patternCallbacks.get(pattern)?.size ?? 0;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
getTotalSubscribers(): number {
|
|
768
|
+
let total = 0;
|
|
769
|
+
for (const subscribers of this.channelCallbacks.values()) {
|
|
770
|
+
total += subscribers.size;
|
|
771
|
+
}
|
|
772
|
+
for (const subscribers of this.patternCallbacks.values()) {
|
|
773
|
+
total += subscribers.size;
|
|
774
|
+
}
|
|
775
|
+
return total;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async clear(): Promise<void> {
|
|
779
|
+
// Unsubscribe from all channels
|
|
780
|
+
for (const channel of this.channelCallbacks.keys()) {
|
|
781
|
+
await this.unsubscribe(channel);
|
|
782
|
+
}
|
|
783
|
+
// Unsubscribe from all patterns
|
|
784
|
+
for (const pattern of this.patternCallbacks.keys()) {
|
|
785
|
+
await this.punsubscribe(pattern);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
destroy(): void {
|
|
790
|
+
this.disconnect().catch(() => {});
|
|
791
|
+
this.channelCallbacks.clear();
|
|
792
|
+
this.patternCallbacks.clear();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ============= Pub/Sub Class (Unified Interface) =============
|
|
797
|
+
|
|
798
|
+
export class PubSub {
|
|
799
|
+
private driver: InMemoryPubSub | RedisPubSub;
|
|
800
|
+
private driverType: "redis" | "memory";
|
|
801
|
+
private _isConnected = false;
|
|
802
|
+
|
|
803
|
+
constructor(config: PubSubConfig = {}) {
|
|
804
|
+
this.driverType = config.driver ?? "memory";
|
|
805
|
+
|
|
806
|
+
if (this.driverType === "redis" && config.url) {
|
|
807
|
+
this.driver = new RedisPubSub(config);
|
|
808
|
+
} else {
|
|
809
|
+
this.driver = new InMemoryPubSub();
|
|
810
|
+
// Memory driver is always "connected"
|
|
811
|
+
this._isConnected = true;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Connect to the pub/sub backend (Redis only)
|
|
817
|
+
*/
|
|
818
|
+
async connect(): Promise<void> {
|
|
819
|
+
if (this.driver instanceof RedisPubSub) {
|
|
820
|
+
await this.driver.connect();
|
|
821
|
+
}
|
|
822
|
+
this._isConnected = true;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Disconnect from the pub/sub backend
|
|
827
|
+
*/
|
|
828
|
+
async disconnect(): Promise<void> {
|
|
829
|
+
if (this.driver instanceof RedisPubSub) {
|
|
830
|
+
await this.driver.disconnect();
|
|
831
|
+
} else {
|
|
832
|
+
this.driver.destroy();
|
|
833
|
+
}
|
|
834
|
+
this._isConnected = false;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Check if connected
|
|
839
|
+
*/
|
|
840
|
+
get isConnected(): boolean {
|
|
841
|
+
return this._isConnected;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Get the driver type
|
|
846
|
+
*/
|
|
847
|
+
getDriverType(): "redis" | "memory" {
|
|
848
|
+
return this.driverType;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Publish a message to a channel
|
|
853
|
+
* Returns the number of subscribers that received the message
|
|
854
|
+
*/
|
|
855
|
+
async publish(channel: string, data: unknown): Promise<number> {
|
|
856
|
+
return this.driver.publish(channel, data);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Subscribe to a channel
|
|
861
|
+
* Returns an unsubscribe function
|
|
862
|
+
*/
|
|
863
|
+
async subscribe(
|
|
864
|
+
channel: string,
|
|
865
|
+
callback: PubSubCallback,
|
|
866
|
+
): Promise<() => void> {
|
|
867
|
+
return this.driver.subscribe(channel, callback);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Subscribe to channels matching a pattern
|
|
872
|
+
* Supports * (any characters) and ? (single character)
|
|
873
|
+
* Returns an unsubscribe function
|
|
874
|
+
*/
|
|
875
|
+
async psubscribe(
|
|
876
|
+
pattern: string,
|
|
877
|
+
callback: PubSubCallback,
|
|
878
|
+
): Promise<() => void> {
|
|
879
|
+
return this.driver.psubscribe(pattern, callback);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Unsubscribe all callbacks from a channel
|
|
884
|
+
*/
|
|
885
|
+
async unsubscribe(channel: string): Promise<void> {
|
|
886
|
+
return this.driver.unsubscribe(channel);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Unsubscribe all callbacks from a pattern
|
|
891
|
+
*/
|
|
892
|
+
async punsubscribe(pattern: string): Promise<void> {
|
|
893
|
+
return this.driver.punsubscribe(pattern);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Get subscriber count for a specific channel
|
|
898
|
+
*/
|
|
899
|
+
getChannelSubscribers(channel: string): number {
|
|
900
|
+
return this.driver.getChannelSubscribers(channel);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Get subscriber count for a specific pattern
|
|
905
|
+
*/
|
|
906
|
+
getPatternSubscribers(pattern: string): number {
|
|
907
|
+
return this.driver.getPatternSubscribers(pattern);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Get total subscriber count across all channels and patterns
|
|
912
|
+
*/
|
|
913
|
+
getTotalSubscribers(): number {
|
|
914
|
+
return this.driver.getTotalSubscribers();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Clear all subscriptions
|
|
919
|
+
*/
|
|
920
|
+
async clear(): Promise<void> {
|
|
921
|
+
return this.driver.clear();
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Destroy the pub/sub instance and release resources
|
|
926
|
+
*/
|
|
927
|
+
destroy(): void {
|
|
928
|
+
this.driver.destroy();
|
|
929
|
+
this._isConnected = false;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ============= Factory Functions =============
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Create a WebSocket server
|
|
937
|
+
*/
|
|
938
|
+
export function createWebSocketServer(
|
|
939
|
+
options?: WebSocketServerOptions,
|
|
940
|
+
): WebSocketServer {
|
|
941
|
+
return new WebSocketServer(options);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Create a WebSocket client
|
|
946
|
+
*/
|
|
947
|
+
export function createWebSocketClient(
|
|
948
|
+
options: WebSocketClientOptions,
|
|
949
|
+
): WebSocketClient {
|
|
950
|
+
return new WebSocketClient(options);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Create a pub/sub instance
|
|
955
|
+
* @param config Configuration options including driver type and Redis URL
|
|
956
|
+
*/
|
|
957
|
+
export function createPubSub(config?: PubSubConfig): PubSub {
|
|
958
|
+
return new PubSub(config);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Create a Redis pub/sub instance (convenience function)
|
|
963
|
+
*/
|
|
964
|
+
export function createRedisPubSub(
|
|
965
|
+
url: string,
|
|
966
|
+
options?: Omit<PubSubConfig, "driver" | "url">,
|
|
967
|
+
): PubSub {
|
|
968
|
+
return new PubSub({ driver: "redis", url, ...options });
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Create an in-memory pub/sub instance (convenience function)
|
|
973
|
+
*/
|
|
974
|
+
export function createMemoryPubSub(): PubSub {
|
|
975
|
+
return new PubSub({ driver: "memory" });
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ============= Upgrade Helper =============
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Check if request is a WebSocket upgrade request
|
|
982
|
+
*/
|
|
983
|
+
export function isWebSocketRequest(request: Request): boolean {
|
|
984
|
+
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Generate WebSocket connection ID
|
|
989
|
+
*/
|
|
990
|
+
export function generateConnectionId(): string {
|
|
991
|
+
return crypto.randomUUID();
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Create WebSocket data for new connection
|
|
996
|
+
*/
|
|
997
|
+
export function createWebSocketData(
|
|
998
|
+
data?: Partial<WebSocketData>,
|
|
999
|
+
): WebSocketData {
|
|
1000
|
+
return {
|
|
1001
|
+
id: generateConnectionId(),
|
|
1002
|
+
...data,
|
|
1003
|
+
};
|
|
1004
|
+
}
|