@fitzzero/quickdraw-core 0.1.0 → 1.0.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.
@@ -22,6 +22,8 @@ var BaseService = class {
22
22
  subscribers = /* @__PURE__ */ new Map();
23
23
  // Prisma delegate for DB operations
24
24
  delegate;
25
+ // Socket.io server instance for room-based broadcasts
26
+ io = null;
25
27
  // Collection of public methods for registry discovery
26
28
  publicMethods = /* @__PURE__ */ new Map();
27
29
  constructor(options) {
@@ -37,6 +39,13 @@ var BaseService = class {
37
39
  setDelegate(delegate) {
38
40
  this.delegate = delegate;
39
41
  }
42
+ /**
43
+ * Set the Socket.io server instance for room-based broadcasts.
44
+ * Called automatically by ServiceRegistry during registration.
45
+ */
46
+ setIo(io) {
47
+ this.io = io;
48
+ }
40
49
  /**
41
50
  * Get the delegate, throwing if not set.
42
51
  */
@@ -51,9 +60,17 @@ var BaseService = class {
51
60
  // ===========================================================================
52
61
  // Subscription Management
53
62
  // ===========================================================================
63
+ /**
64
+ * Get the Socket.io room name for an entry.
65
+ * Used for room-based broadcasting across services.
66
+ */
67
+ getRoomName(entryId) {
68
+ return `${this.serviceName}:${entryId}`;
69
+ }
54
70
  /**
55
71
  * Subscribe a socket to an entity's updates.
56
72
  * Returns the current entity data if access is granted, null otherwise.
73
+ * Also joins the socket to the entity's Socket.io room for cross-service events.
57
74
  */
58
75
  async subscribe(entryId, socket, requiredLevel = "Read") {
59
76
  if (!socket.userId) {
@@ -72,9 +89,14 @@ var BaseService = class {
72
89
  this.subscribers.set(entryId, /* @__PURE__ */ new Set());
73
90
  }
74
91
  this.subscribers.get(entryId).add(socket);
75
- this.logger.debug(`User ${socket.userId} subscribed to ${entryId}`);
92
+ const roomName = this.getRoomName(entryId);
93
+ void socket.join(roomName);
94
+ this.logger.debug(
95
+ `User ${socket.userId} subscribed to ${entryId} (room: ${roomName})`
96
+ );
76
97
  const entity = await this.findById(entryId);
77
- return entity;
98
+ if (!entity) return null;
99
+ return this.filterEntityForSubscriber(entity, socket, entryId);
78
100
  }
79
101
  /**
80
102
  * Unsubscribe a socket from an entity's updates.
@@ -87,6 +109,8 @@ var BaseService = class {
87
109
  this.subscribers.delete(entryId);
88
110
  }
89
111
  }
112
+ const roomName = this.getRoomName(entryId);
113
+ void socket.leave(roomName);
90
114
  }
91
115
  /**
92
116
  * Remove a socket from all subscriptions (called on disconnect).
@@ -95,22 +119,74 @@ var BaseService = class {
95
119
  for (const [entryId, sockets] of this.subscribers.entries()) {
96
120
  if (sockets.has(socket)) {
97
121
  sockets.delete(socket);
122
+ const roomName = this.getRoomName(entryId);
123
+ void socket.leave(roomName);
98
124
  if (sockets.size === 0) {
99
125
  this.subscribers.delete(entryId);
100
126
  }
101
127
  }
102
128
  }
103
129
  }
130
+ /**
131
+ * Emit a custom event to all subscribers of an entry via Socket.io rooms.
132
+ * This is useful for cross-service events (e.g., message service notifying chat subscribers).
133
+ *
134
+ * @param roomName - The Socket.io room name (use getRoomName for service rooms)
135
+ * @param eventName - The event name to emit
136
+ * @param data - The data to emit
137
+ *
138
+ * @example
139
+ * ```typescript
140
+ * // In MessageService, notify chat subscribers of a new message
141
+ * this.emitToRoom(
142
+ * `chatService:${message.chatId}`,
143
+ * 'chat:message',
144
+ * messageDTO
145
+ * );
146
+ * ```
147
+ */
148
+ emitToRoom(roomName, eventName, data) {
149
+ if (this.io) {
150
+ this.io.to(roomName).emit(eventName, data);
151
+ this.logger.debug(`Emitted ${eventName} to room ${roomName}`);
152
+ return;
153
+ }
154
+ const anySocket = this.getAnySubscriberSocket();
155
+ if (!anySocket) {
156
+ this.logger.debug(`No sockets available to emit to room ${roomName}`);
157
+ return;
158
+ }
159
+ anySocket.to(roomName).emit(eventName, data);
160
+ this.logger.debug(
161
+ `Emitted ${eventName} to room ${roomName} (via socket fallback)`
162
+ );
163
+ }
164
+ /**
165
+ * Get any active socket for room-based operations.
166
+ */
167
+ getAnySubscriberSocket() {
168
+ for (const sockets of this.subscribers.values()) {
169
+ const first = sockets.values().next();
170
+ if (!first.done) {
171
+ return first.value;
172
+ }
173
+ }
174
+ return null;
175
+ }
104
176
  /**
105
177
  * Emit an update to all subscribers of an entity.
178
+ * Uses tier-based pre-filtering: computes filtered versions once,
179
+ * then selects the appropriate version per subscriber.
106
180
  */
107
181
  emitUpdate(entryId, data) {
108
- const eventName = `${this.serviceName}:update:${entryId}`;
109
182
  const subs = this.subscribers.get(entryId);
110
- if (subs) {
111
- for (const socket of subs) {
112
- socket.emit(eventName, data);
113
- }
183
+ if (!subs || subs.size === 0) return;
184
+ const eventName = `${this.serviceName}:update:${entryId}`;
185
+ const fullData = data;
186
+ const publicData = this.stripProtectedFields(data);
187
+ for (const socket of subs) {
188
+ const payload = this.hasElevatedAccess(socket, entryId) ? fullData : publicData;
189
+ socket.emit(eventName, payload);
114
190
  }
115
191
  }
116
192
  // ===========================================================================
@@ -206,6 +282,72 @@ var BaseService = class {
206
282
  throw new Error("Insufficient permissions");
207
283
  }
208
284
  // ===========================================================================
285
+ // Protected Fields Filtering
286
+ // ===========================================================================
287
+ /**
288
+ * Get the list of fields that should be stripped for non-elevated subscribers.
289
+ * Override in derived classes to customize which fields are protected.
290
+ *
291
+ * @example
292
+ * ```typescript
293
+ * protected override getProtectedFields(): (keyof User)[] {
294
+ * return ['email', 'serviceAccess', 'discordId'];
295
+ * }
296
+ * ```
297
+ */
298
+ getProtectedFields() {
299
+ return ["email", "serviceAccess"];
300
+ }
301
+ /**
302
+ * Check if a socket has elevated access to an entity (receives full data).
303
+ * Default: owner (socket.userId === entryId) or service-level Admin.
304
+ * Override in derived classes for custom logic.
305
+ *
306
+ * @example
307
+ * ```typescript
308
+ * protected override hasElevatedAccess(socket: QuickdrawSocket, entryId: string): boolean {
309
+ * // Friends can see more data
310
+ * return super.hasElevatedAccess(socket, entryId) ||
311
+ * this.isFriend(socket.userId, entryId);
312
+ * }
313
+ * ```
314
+ */
315
+ hasElevatedAccess(socket, entryId) {
316
+ return socket.userId === entryId || this.hasServiceAccess(socket, "Admin");
317
+ }
318
+ /**
319
+ * Strip protected fields from an entity.
320
+ * Used internally by filterEntityForSubscriber.
321
+ */
322
+ stripProtectedFields(entity) {
323
+ const protectedFields = this.getProtectedFields();
324
+ const result = { ...entity };
325
+ for (const field of protectedFields) {
326
+ delete result[field];
327
+ }
328
+ return result;
329
+ }
330
+ /**
331
+ * Filter entity data based on subscriber's access level.
332
+ * Override for complex filtering logic (e.g., multiple tiers).
333
+ *
334
+ * @example
335
+ * ```typescript
336
+ * protected override filterEntityForSubscriber(
337
+ * entity: Partial<TEntity>,
338
+ * socket: QuickdrawSocket,
339
+ * entryId: string
340
+ * ): Partial<TEntity> {
341
+ * if (this.hasElevatedAccess(socket, entryId)) return entity;
342
+ * if (this.isFriend(socket.userId, entryId)) return this.stripForFriends(entity);
343
+ * return this.stripProtectedFields(entity);
344
+ * }
345
+ * ```
346
+ */
347
+ filterEntityForSubscriber(entity, socket, entryId) {
348
+ return this.hasElevatedAccess(socket, entryId) ? entity : this.stripProtectedFields(entity);
349
+ }
350
+ // ===========================================================================
209
351
  // CRUD Operations
210
352
  // ===========================================================================
211
353
  /**
@@ -285,14 +427,249 @@ var BaseService = class {
285
427
  return Array.from(this.publicMethods.values());
286
428
  }
287
429
  // ===========================================================================
288
- // Admin Methods (Scaffolding)
430
+ // Admin Methods
289
431
  // ===========================================================================
290
432
  /**
291
433
  * Install standard admin CRUD methods.
292
434
  * Call this in derived class constructor to expose admin endpoints.
435
+ *
436
+ * @example
437
+ * ```typescript
438
+ * this.installAdminMethods({
439
+ * expose: { list: true, get: true, create: true, update: true, delete: true },
440
+ * access: {
441
+ * list: "Admin",
442
+ * get: "Admin",
443
+ * create: "Admin",
444
+ * update: "Admin",
445
+ * delete: "Admin",
446
+ * setEntryACL: "Admin",
447
+ * getSubscribers: "Admin",
448
+ * reemit: "Admin",
449
+ * unsubscribeAll: "Admin",
450
+ * },
451
+ * });
452
+ * ```
453
+ */
454
+ installAdminMethods(options) {
455
+ const { expose, access } = options;
456
+ if (expose.list) {
457
+ this.publicMethods.set("adminList", {
458
+ name: "adminList",
459
+ access: access.list,
460
+ handler: async (payload, _context) => {
461
+ return await this.adminList(payload);
462
+ }
463
+ });
464
+ }
465
+ if (expose.get) {
466
+ this.publicMethods.set("adminGet", {
467
+ name: "adminGet",
468
+ access: access.get,
469
+ handler: async (payload, _context) => {
470
+ return await this.adminGet(payload.id);
471
+ },
472
+ resolveEntryId: (payload) => payload.id
473
+ });
474
+ }
475
+ if (expose.create) {
476
+ this.publicMethods.set("adminCreate", {
477
+ name: "adminCreate",
478
+ access: access.create,
479
+ handler: async (payload, _context) => {
480
+ return await this.adminCreate(
481
+ payload.data
482
+ );
483
+ }
484
+ });
485
+ }
486
+ if (expose.update) {
487
+ this.publicMethods.set("adminUpdate", {
488
+ name: "adminUpdate",
489
+ access: access.update,
490
+ handler: async (payload, _context) => {
491
+ const { id, data } = payload;
492
+ return await this.adminUpdate(id, data);
493
+ },
494
+ resolveEntryId: (payload) => payload.id
495
+ });
496
+ }
497
+ if (expose.delete) {
498
+ this.publicMethods.set("adminDelete", {
499
+ name: "adminDelete",
500
+ access: access.delete,
501
+ handler: async (payload, _context) => {
502
+ return await this.adminDelete(payload.id);
503
+ },
504
+ resolveEntryId: (payload) => payload.id
505
+ });
506
+ }
507
+ if (expose.setEntryACL) {
508
+ this.publicMethods.set("adminSetEntryACL", {
509
+ name: "adminSetEntryACL",
510
+ access: access.setEntryACL,
511
+ handler: async (payload, _context) => {
512
+ return await this.adminSetEntryACL(payload);
513
+ },
514
+ resolveEntryId: (payload) => payload.entryId
515
+ });
516
+ }
517
+ if (expose.getSubscribers) {
518
+ this.publicMethods.set("adminGetSubscribers", {
519
+ name: "adminGetSubscribers",
520
+ access: access.getSubscribers,
521
+ handler: async (payload, _context) => {
522
+ return this.adminGetSubscribers(
523
+ payload.entryId
524
+ );
525
+ },
526
+ resolveEntryId: (payload) => payload.entryId
527
+ });
528
+ }
529
+ if (expose.reemit) {
530
+ this.publicMethods.set("adminReemit", {
531
+ name: "adminReemit",
532
+ access: access.reemit,
533
+ handler: async (payload, _context) => {
534
+ return await this.adminReemit(
535
+ payload.entryId
536
+ );
537
+ },
538
+ resolveEntryId: (payload) => payload.entryId
539
+ });
540
+ }
541
+ if (expose.unsubscribeAll) {
542
+ this.publicMethods.set("adminUnsubscribeAll", {
543
+ name: "adminUnsubscribeAll",
544
+ access: access.unsubscribeAll,
545
+ handler: async (payload, _context) => {
546
+ return this.adminUnsubscribeAll(
547
+ payload.entryId
548
+ );
549
+ },
550
+ resolveEntryId: (payload) => payload.entryId
551
+ });
552
+ }
553
+ this.logger.info(
554
+ `Installed admin methods: ${Object.keys(expose).filter((k) => expose[k]).join(", ")}`
555
+ );
556
+ }
557
+ /**
558
+ * Admin method: List entities with pagination and filters.
559
+ */
560
+ async adminList(payload) {
561
+ const { page = 1, pageSize = 20, where, orderBy } = payload;
562
+ const skip = (page - 1) * pageSize;
563
+ const [items, total] = await Promise.all([
564
+ this.getDelegate().findMany({
565
+ where,
566
+ orderBy,
567
+ skip,
568
+ take: pageSize
569
+ }),
570
+ this.getDelegate().count({ where })
571
+ ]);
572
+ return {
573
+ items,
574
+ total,
575
+ page,
576
+ pageSize,
577
+ totalPages: Math.ceil(total / pageSize)
578
+ };
579
+ }
580
+ /**
581
+ * Admin method: Get a single entity by ID.
582
+ */
583
+ async adminGet(id) {
584
+ return await this.findById(id);
585
+ }
586
+ /**
587
+ * Admin method: Create an entity.
588
+ */
589
+ async adminCreate(data) {
590
+ return await this.create(data);
591
+ }
592
+ /**
593
+ * Admin method: Update an entity.
594
+ */
595
+ async adminUpdate(id, data) {
596
+ return await this.update(id, data);
597
+ }
598
+ /**
599
+ * Admin method: Delete an entity.
600
+ */
601
+ async adminDelete(id) {
602
+ const deleted = await this.delete(id);
603
+ return { success: deleted, id };
604
+ }
605
+ /**
606
+ * Admin method: Set entry-level ACL.
607
+ */
608
+ async adminSetEntryACL(payload) {
609
+ const { entryId, acl } = payload;
610
+ try {
611
+ const entity = await this.getDelegate().update({
612
+ where: { id: entryId },
613
+ data: { acl }
614
+ });
615
+ this.emitUpdate(entryId, entity);
616
+ this.logger.info(`Updated ACL for entity ${entryId}`);
617
+ return entity;
618
+ } catch (error) {
619
+ this.logger.error(`Failed to set ACL for ${entryId}`, {
620
+ error: error instanceof Error ? error.message : String(error)
621
+ });
622
+ return null;
623
+ }
624
+ }
625
+ /**
626
+ * Admin method: Get active subscribers for an entity.
627
+ */
628
+ adminGetSubscribers(entryId) {
629
+ const subs = this.subscribers.get(entryId);
630
+ if (!subs) {
631
+ return { entryId, subscribers: [], count: 0 };
632
+ }
633
+ const subscribers = Array.from(subs).map((socket) => ({
634
+ socketId: socket.id,
635
+ userId: socket.userId ?? null
636
+ }));
637
+ return {
638
+ entryId,
639
+ subscribers,
640
+ count: subscribers.length
641
+ };
642
+ }
643
+ /**
644
+ * Admin method: Re-emit current entity state to all subscribers.
645
+ * Useful when you've made direct DB changes or need to force-refresh clients.
293
646
  */
294
- installAdminMethods(_options) {
295
- this.logger.debug("Admin methods installation placeholder");
647
+ async adminReemit(entryId) {
648
+ const entity = await this.findById(entryId);
649
+ if (!entity) {
650
+ return { success: false, subscriberCount: 0 };
651
+ }
652
+ const subs = this.subscribers.get(entryId);
653
+ const count = subs?.size ?? 0;
654
+ this.emitUpdate(entryId, entity);
655
+ this.logger.info(`Re-emitted entity ${entryId} to ${count} subscribers`);
656
+ return { success: true, subscriberCount: count };
657
+ }
658
+ /**
659
+ * Admin method: Unsubscribe all sockets from an entity.
660
+ */
661
+ adminUnsubscribeAll(entryId) {
662
+ const subs = this.subscribers.get(entryId);
663
+ const count = subs?.size ?? 0;
664
+ if (subs) {
665
+ const eventName = `${this.serviceName}:unsubscribed:${entryId}`;
666
+ for (const socket of subs) {
667
+ socket.emit(eventName, { reason: "admin_action" });
668
+ }
669
+ this.subscribers.delete(entryId);
670
+ }
671
+ this.logger.info(`Unsubscribed ${count} sockets from entity ${entryId}`);
672
+ return { success: true, unsubscribedCount: count };
296
673
  }
297
674
  // ===========================================================================
298
675
  // Utility Methods
@@ -310,47 +687,72 @@ var ServiceRegistry = class {
310
687
  io;
311
688
  services = /* @__PURE__ */ new Map();
312
689
  logger;
690
+ connectionHandlerInstalled = false;
691
+ // Store method metadata for deferred registration
692
+ methodRegistry = /* @__PURE__ */ new Map();
313
693
  constructor(io, options) {
314
694
  this.io = io;
315
695
  this.logger = options?.logger?.child({ service: "ServiceRegistry" }) ?? consoleLogger.child({ service: "ServiceRegistry" });
316
696
  }
697
+ /**
698
+ * Install the single connection handler that wires all registered services.
699
+ * This is called lazily on first service registration.
700
+ */
701
+ installConnectionHandler() {
702
+ if (this.connectionHandlerInstalled) return;
703
+ this.connectionHandlerInstalled = true;
704
+ this.io.on("connection", (socket) => {
705
+ const quickdrawSocket = socket;
706
+ this.setupSocketListeners(quickdrawSocket);
707
+ });
708
+ this.logger.info("Installed single connection handler for all services");
709
+ }
710
+ /**
711
+ * Set up all event listeners for a newly connected socket.
712
+ */
713
+ setupSocketListeners(socket) {
714
+ for (const [eventName, { method, service }] of this.methodRegistry) {
715
+ this.registerMethodListener(socket, eventName, method, service);
716
+ }
717
+ for (const [serviceName, service] of this.services) {
718
+ this.registerSubscriptionListener(
719
+ socket,
720
+ `${serviceName}:subscribe`,
721
+ service
722
+ );
723
+ this.registerUnsubscriptionListener(
724
+ socket,
725
+ `${serviceName}:unsubscribe`,
726
+ service
727
+ );
728
+ }
729
+ }
317
730
  /**
318
731
  * Register a service and auto-discover its public methods.
319
732
  */
320
733
  registerService(serviceName, service) {
321
734
  this.services.set(serviceName, service);
735
+ if (service.setIo) {
736
+ service.setIo(this.io);
737
+ }
322
738
  this.logger.info(`Registered service: ${serviceName}`);
323
739
  this.discoverServiceMethods(serviceName, service);
740
+ this.installConnectionHandler();
324
741
  }
325
742
  /**
326
- * Discover and register all public methods from a service.
743
+ * Discover and store all public methods from a service.
327
744
  */
328
745
  discoverServiceMethods(serviceName, service) {
329
746
  const methods = service.getPublicMethods();
330
747
  for (const method of methods) {
331
748
  const eventName = `${serviceName}:${method.name}`;
332
749
  this.logger.info(`Registering socket event: ${eventName}`);
333
- this.io.on("connection", (socket) => {
334
- this.registerMethodListener(
335
- socket,
336
- eventName,
337
- method,
338
- service
339
- );
750
+ this.methodRegistry.set(eventName, {
751
+ serviceName,
752
+ method,
753
+ service
340
754
  });
341
755
  }
342
- this.io.on("connection", (socket) => {
343
- this.registerSubscriptionListener(
344
- socket,
345
- `${serviceName}:subscribe`,
346
- service
347
- );
348
- this.registerUnsubscriptionListener(
349
- socket,
350
- `${serviceName}:unsubscribe`,
351
- service
352
- );
353
- });
354
756
  }
355
757
  /**
356
758
  * Register a method as a socket event listener.
@@ -594,6 +996,216 @@ function createQuickdrawServer(options) {
594
996
  });
595
997
  return { io, httpServer, registry };
596
998
  }
999
+
1000
+ // src/server/redis.ts
1001
+ async function setupRedisAdapter(io, options = {}) {
1002
+ const logger = options.logger?.child({ service: "RedisAdapter" }) ?? consoleLogger.child({ service: "RedisAdapter" });
1003
+ const {
1004
+ host = "localhost",
1005
+ port = 6379,
1006
+ password,
1007
+ db = 0,
1008
+ keyPrefix = "socket.io"
1009
+ } = options;
1010
+ try {
1011
+ const dynamicImport = new Function("specifier", "return import(specifier)");
1012
+ const [redisAdapterModule, redisModule] = await Promise.all([
1013
+ dynamicImport("@socket.io/redis-adapter"),
1014
+ dynamicImport("redis")
1015
+ ]);
1016
+ const createAdapter = redisAdapterModule.createAdapter;
1017
+ const createClient = redisModule.createClient;
1018
+ const pubClient = createClient({
1019
+ socket: { host, port },
1020
+ password,
1021
+ database: db
1022
+ });
1023
+ const subClient = pubClient.duplicate();
1024
+ await Promise.all([pubClient.connect(), subClient.connect()]);
1025
+ io.adapter(createAdapter(pubClient, subClient, { key: keyPrefix }));
1026
+ logger.info(`Redis adapter connected to ${host}:${port}`);
1027
+ const cleanup = async () => {
1028
+ try {
1029
+ await Promise.all([pubClient.quit(), subClient.quit()]);
1030
+ logger.info("Redis adapter disconnected");
1031
+ } catch (error) {
1032
+ logger.error("Error disconnecting Redis adapter", {
1033
+ error: error instanceof Error ? error.message : String(error)
1034
+ });
1035
+ }
1036
+ };
1037
+ return { success: true, cleanup };
1038
+ } catch (error) {
1039
+ const errorMessage = error instanceof Error ? error.message : String(error);
1040
+ if (errorMessage.includes("Cannot find module") || errorMessage.includes("MODULE_NOT_FOUND")) {
1041
+ logger.warn(
1042
+ "Redis adapter packages not installed. Install @socket.io/redis-adapter and redis for horizontal scaling support."
1043
+ );
1044
+ } else {
1045
+ logger.error("Failed to set up Redis adapter", { error: errorMessage });
1046
+ }
1047
+ return {
1048
+ success: false,
1049
+ cleanup: async () => {
1050
+ }
1051
+ };
1052
+ }
1053
+ }
1054
+ async function isRedisAdapterAvailable() {
1055
+ try {
1056
+ const dynamicImport = new Function("specifier", "return import(specifier)");
1057
+ await Promise.all([
1058
+ dynamicImport("@socket.io/redis-adapter"),
1059
+ dynamicImport("redis")
1060
+ ]);
1061
+ return true;
1062
+ } catch {
1063
+ return false;
1064
+ }
1065
+ }
1066
+
1067
+ // src/server/rateLimit.ts
1068
+ function createRateLimiter(options = {}) {
1069
+ const {
1070
+ windowMs = 6e4,
1071
+ maxRequests = 100,
1072
+ excludeEvents = []
1073
+ } = options;
1074
+ const entries = /* @__PURE__ */ new Map();
1075
+ const cleanupInterval = setInterval(() => {
1076
+ const now = Date.now();
1077
+ for (const [key, entry] of entries) {
1078
+ if (entry.resetAt <= now) {
1079
+ entries.delete(key);
1080
+ }
1081
+ }
1082
+ }, windowMs);
1083
+ if (cleanupInterval.unref) {
1084
+ cleanupInterval.unref();
1085
+ }
1086
+ return {
1087
+ check(key) {
1088
+ const now = Date.now();
1089
+ const entry = entries.get(key);
1090
+ if (!entry || entry.resetAt <= now) {
1091
+ entries.set(key, {
1092
+ count: 1,
1093
+ resetAt: now + windowMs
1094
+ });
1095
+ return true;
1096
+ }
1097
+ if (entry.count >= maxRequests) {
1098
+ return false;
1099
+ }
1100
+ entry.count++;
1101
+ return true;
1102
+ },
1103
+ getRemaining(key) {
1104
+ const now = Date.now();
1105
+ const entry = entries.get(key);
1106
+ if (!entry || entry.resetAt <= now) {
1107
+ return maxRequests;
1108
+ }
1109
+ return Math.max(0, maxRequests - entry.count);
1110
+ },
1111
+ getResetTime(key) {
1112
+ const now = Date.now();
1113
+ const entry = entries.get(key);
1114
+ if (!entry || entry.resetAt <= now) {
1115
+ return 0;
1116
+ }
1117
+ return entry.resetAt - now;
1118
+ },
1119
+ clear() {
1120
+ entries.clear();
1121
+ },
1122
+ options: {
1123
+ windowMs,
1124
+ maxRequests,
1125
+ excludeEvents
1126
+ }
1127
+ };
1128
+ }
1129
+ function applyRateLimitMiddleware(io, rateLimiter, options = {}) {
1130
+ const logger = options.logger?.child({ service: "RateLimiter" }) ?? consoleLogger.child({ service: "RateLimiter" });
1131
+ const keyGenerator = options.keyGenerator ?? ((socket) => socket.id);
1132
+ const onRateLimitExceeded = options.onRateLimitExceeded ?? ((socket, eventName) => {
1133
+ socket.emit("error", {
1134
+ code: "RATE_LIMITED",
1135
+ message: `Rate limit exceeded for event: ${eventName}`,
1136
+ retryAfter: rateLimiter.getResetTime(keyGenerator(socket, eventName))
1137
+ });
1138
+ });
1139
+ const { excludeEvents } = rateLimiter.options;
1140
+ io.on("connection", (socket) => {
1141
+ socket.use(([eventName, ...args], next) => {
1142
+ if (excludeEvents.includes(eventName)) {
1143
+ next();
1144
+ return;
1145
+ }
1146
+ if (eventName.startsWith("$")) {
1147
+ next();
1148
+ return;
1149
+ }
1150
+ const key = keyGenerator(socket, eventName);
1151
+ if (rateLimiter.check(key)) {
1152
+ next();
1153
+ } else {
1154
+ logger.warn(`Rate limit exceeded for socket ${socket.id} on event ${eventName}`);
1155
+ onRateLimitExceeded(socket, eventName);
1156
+ const lastArg = args[args.length - 1];
1157
+ if (typeof lastArg === "function") {
1158
+ lastArg({
1159
+ success: false,
1160
+ error: "Rate limit exceeded",
1161
+ code: 429
1162
+ });
1163
+ }
1164
+ }
1165
+ });
1166
+ });
1167
+ logger.info(
1168
+ `Rate limiting enabled: ${rateLimiter.options.maxRequests} requests per ${rateLimiter.options.windowMs}ms`
1169
+ );
1170
+ }
1171
+ function createTieredRateLimiter(tiers, defaultOptions = {}) {
1172
+ const tierLimiters = /* @__PURE__ */ new Map();
1173
+ const tierPatterns = [];
1174
+ for (const [pattern, options] of Object.entries(tiers)) {
1175
+ const limiter = createRateLimiter({ ...defaultOptions, ...options });
1176
+ tierLimiters.set(pattern, limiter);
1177
+ const regexPattern = pattern.replace(/\*/g, ".*").replace(/\?/g, ".");
1178
+ tierPatterns.push({
1179
+ pattern: new RegExp(`^${regexPattern}$`),
1180
+ limiter
1181
+ });
1182
+ }
1183
+ const defaultLimiter = createRateLimiter(defaultOptions);
1184
+ function getLimiterForEvent(eventName) {
1185
+ for (const { pattern, limiter } of tierPatterns) {
1186
+ if (pattern.test(eventName)) {
1187
+ return limiter;
1188
+ }
1189
+ }
1190
+ return defaultLimiter;
1191
+ }
1192
+ return {
1193
+ check(key, eventName) {
1194
+ const limiter = getLimiterForEvent(eventName);
1195
+ return limiter.check(`${key}:${eventName}`);
1196
+ },
1197
+ getRemaining(key, eventName) {
1198
+ const limiter = getLimiterForEvent(eventName);
1199
+ return limiter.getRemaining(`${key}:${eventName}`);
1200
+ },
1201
+ clear() {
1202
+ for (const limiter of tierLimiters.values()) {
1203
+ limiter.clear();
1204
+ }
1205
+ defaultLimiter.clear();
1206
+ }
1207
+ };
1208
+ }
597
1209
  var DEFAULT_EXPIRATION = "7d";
598
1210
  async function createJWT(payload, secret, expiresIn = DEFAULT_EXPIRATION) {
599
1211
  const secretKey = new TextEncoder().encode(secret);
@@ -702,6 +1314,6 @@ var googleProvider = {
702
1314
  }
703
1315
  };
704
1316
 
705
- export { BaseService, ServiceRegistry, createJWT, createOAuthURL, createQuickdrawServer, discordProvider, exchangeOAuthCode, googleProvider, verifyJWT };
1317
+ export { BaseService, ServiceRegistry, applyRateLimitMiddleware, createJWT, createOAuthURL, createQuickdrawServer, createRateLimiter, createTieredRateLimiter, discordProvider, exchangeOAuthCode, googleProvider, isRedisAdapterAvailable, setupRedisAdapter, verifyJWT };
706
1318
  //# sourceMappingURL=index.js.map
707
1319
  //# sourceMappingURL=index.js.map