@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.
Files changed (120) hide show
  1. package/.env.example +109 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/LICENSE +21 -0
  4. package/README.md +892 -0
  5. package/architecture.md +652 -0
  6. package/bun.lock +70 -0
  7. package/dist/cli/index.js +3233 -0
  8. package/dist/index.js +9014 -0
  9. package/package.json +77 -0
  10. package/src/cache/index.ts +795 -0
  11. package/src/cli/ARCHITECTURE.md +837 -0
  12. package/src/cli/bin.ts +10 -0
  13. package/src/cli/commands/build.ts +425 -0
  14. package/src/cli/commands/dev.ts +248 -0
  15. package/src/cli/commands/generate.ts +541 -0
  16. package/src/cli/commands/help.ts +55 -0
  17. package/src/cli/commands/index.ts +112 -0
  18. package/src/cli/commands/migration.ts +355 -0
  19. package/src/cli/commands/new.ts +804 -0
  20. package/src/cli/commands/start.ts +208 -0
  21. package/src/cli/core/args.ts +283 -0
  22. package/src/cli/core/console.ts +349 -0
  23. package/src/cli/core/index.ts +60 -0
  24. package/src/cli/core/prompt.ts +424 -0
  25. package/src/cli/core/spinner.ts +265 -0
  26. package/src/cli/index.ts +135 -0
  27. package/src/cli/templates/deploy.ts +295 -0
  28. package/src/cli/templates/docker.ts +307 -0
  29. package/src/cli/templates/index.ts +24 -0
  30. package/src/cli/utils/fs.ts +428 -0
  31. package/src/cli/utils/index.ts +8 -0
  32. package/src/cli/utils/strings.ts +197 -0
  33. package/src/config/env.ts +408 -0
  34. package/src/config/index.ts +506 -0
  35. package/src/config/loader.ts +329 -0
  36. package/src/config/merge.ts +285 -0
  37. package/src/config/types.ts +320 -0
  38. package/src/config/validation.ts +441 -0
  39. package/src/container/forward-ref.ts +143 -0
  40. package/src/container/index.ts +386 -0
  41. package/src/context/index.ts +360 -0
  42. package/src/database/index.ts +1142 -0
  43. package/src/database/migrations/index.ts +371 -0
  44. package/src/database/schema/index.ts +619 -0
  45. package/src/frontend/api-routes.ts +640 -0
  46. package/src/frontend/bundler.ts +643 -0
  47. package/src/frontend/console-client.ts +419 -0
  48. package/src/frontend/console-stream.ts +587 -0
  49. package/src/frontend/dev-server.ts +846 -0
  50. package/src/frontend/file-router.ts +611 -0
  51. package/src/frontend/frameworks/index.ts +106 -0
  52. package/src/frontend/frameworks/react.ts +85 -0
  53. package/src/frontend/frameworks/solid.ts +104 -0
  54. package/src/frontend/frameworks/svelte.ts +110 -0
  55. package/src/frontend/frameworks/vue.ts +92 -0
  56. package/src/frontend/hmr-client.ts +663 -0
  57. package/src/frontend/hmr.ts +728 -0
  58. package/src/frontend/index.ts +342 -0
  59. package/src/frontend/islands.ts +552 -0
  60. package/src/frontend/isr.ts +555 -0
  61. package/src/frontend/layout.ts +475 -0
  62. package/src/frontend/ssr/react.ts +446 -0
  63. package/src/frontend/ssr/solid.ts +523 -0
  64. package/src/frontend/ssr/svelte.ts +546 -0
  65. package/src/frontend/ssr/vue.ts +504 -0
  66. package/src/frontend/ssr.ts +699 -0
  67. package/src/frontend/types.ts +2274 -0
  68. package/src/health/index.ts +604 -0
  69. package/src/index.ts +410 -0
  70. package/src/lock/index.ts +587 -0
  71. package/src/logger/index.ts +444 -0
  72. package/src/logger/transports/index.ts +969 -0
  73. package/src/metrics/index.ts +494 -0
  74. package/src/middleware/built-in.ts +360 -0
  75. package/src/middleware/index.ts +94 -0
  76. package/src/modules/filters.ts +458 -0
  77. package/src/modules/guards.ts +405 -0
  78. package/src/modules/index.ts +1256 -0
  79. package/src/modules/interceptors.ts +574 -0
  80. package/src/modules/lazy.ts +418 -0
  81. package/src/modules/lifecycle.ts +478 -0
  82. package/src/modules/metadata.ts +90 -0
  83. package/src/modules/pipes.ts +626 -0
  84. package/src/router/index.ts +339 -0
  85. package/src/router/linear.ts +371 -0
  86. package/src/router/regex.ts +292 -0
  87. package/src/router/tree.ts +562 -0
  88. package/src/rpc/index.ts +1263 -0
  89. package/src/security/index.ts +436 -0
  90. package/src/ssg/index.ts +631 -0
  91. package/src/storage/index.ts +456 -0
  92. package/src/telemetry/index.ts +1097 -0
  93. package/src/testing/index.ts +1586 -0
  94. package/src/types/index.ts +236 -0
  95. package/src/types/optional-deps.d.ts +219 -0
  96. package/src/validation/index.ts +276 -0
  97. package/src/websocket/index.ts +1004 -0
  98. package/tests/integration/cli.test.ts +1016 -0
  99. package/tests/integration/fullstack.test.ts +234 -0
  100. package/tests/unit/cache.test.ts +174 -0
  101. package/tests/unit/cli-commands.test.ts +892 -0
  102. package/tests/unit/cli.test.ts +1258 -0
  103. package/tests/unit/container.test.ts +279 -0
  104. package/tests/unit/context.test.ts +221 -0
  105. package/tests/unit/database.test.ts +183 -0
  106. package/tests/unit/linear-router.test.ts +280 -0
  107. package/tests/unit/lock.test.ts +336 -0
  108. package/tests/unit/middleware.test.ts +184 -0
  109. package/tests/unit/modules.test.ts +142 -0
  110. package/tests/unit/pubsub.test.ts +257 -0
  111. package/tests/unit/regex-router.test.ts +265 -0
  112. package/tests/unit/router.test.ts +373 -0
  113. package/tests/unit/rpc.test.ts +1248 -0
  114. package/tests/unit/security.test.ts +174 -0
  115. package/tests/unit/telemetry.test.ts +371 -0
  116. package/tests/unit/test-cache.test.ts +110 -0
  117. package/tests/unit/test-database.test.ts +282 -0
  118. package/tests/unit/tree-router.test.ts +325 -0
  119. package/tests/unit/validation.test.ts +794 -0
  120. 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
+ }