@fitzzero/quickdraw-core 0.1.0 → 1.1.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.
@@ -12,6 +12,168 @@ var consoleLogger = {
12
12
  child: () => consoleLogger
13
13
  };
14
14
 
15
+ // src/server/utils/zodToAdminFields.ts
16
+ var NON_EDITABLE_FIELDS = /* @__PURE__ */ new Set([
17
+ "id",
18
+ "createdAt",
19
+ "updatedAt",
20
+ "created_at",
21
+ "updated_at"
22
+ ]);
23
+ var DEFAULT_HIDDEN_FIELDS = /* @__PURE__ */ new Set([
24
+ "acl",
25
+ "serviceAccess",
26
+ "service_access"
27
+ ]);
28
+ function toLabel(fieldName) {
29
+ return fieldName.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
30
+ }
31
+ function getFieldType(zodType) {
32
+ const typeName = zodType._def.typeName;
33
+ if (typeName === "ZodOptional" || typeName === "ZodNullable" || typeName === "ZodDefault") {
34
+ const inner = zodType._def.innerType;
35
+ return getFieldType(inner);
36
+ }
37
+ switch (typeName) {
38
+ case "ZodString":
39
+ return "string";
40
+ case "ZodNumber":
41
+ return "number";
42
+ case "ZodBoolean":
43
+ return "boolean";
44
+ case "ZodDate":
45
+ return "date";
46
+ case "ZodEnum":
47
+ case "ZodNativeEnum":
48
+ return "enum";
49
+ case "ZodObject":
50
+ case "ZodArray":
51
+ case "ZodRecord":
52
+ return "json";
53
+ default:
54
+ return "string";
55
+ }
56
+ }
57
+ function getEnumValues(zodType) {
58
+ const typeName = zodType._def.typeName;
59
+ if (typeName === "ZodOptional" || typeName === "ZodNullable" || typeName === "ZodDefault") {
60
+ const inner = zodType._def.innerType;
61
+ return getEnumValues(inner);
62
+ }
63
+ if (typeName === "ZodEnum") {
64
+ return zodType._def.values;
65
+ }
66
+ if (typeName === "ZodNativeEnum") {
67
+ const enumObj = zodType._def.values;
68
+ return Object.values(enumObj).filter(
69
+ (v) => typeof v === "string"
70
+ );
71
+ }
72
+ return void 0;
73
+ }
74
+ function isOptional(zodType) {
75
+ const typeName = zodType._def.typeName;
76
+ if (typeName === "ZodOptional" || typeName === "ZodNullable") {
77
+ return true;
78
+ }
79
+ if (typeName === "ZodDefault") {
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+ function zodToAdminFields(schema, options = {}) {
85
+ const { hiddenFields = [], tableColumns, fieldOverrides = {} } = options;
86
+ let shape = {};
87
+ let currentSchema = schema;
88
+ while (currentSchema) {
89
+ const typeName = currentSchema._def.typeName;
90
+ if (typeName === "ZodObject") {
91
+ shape = currentSchema._def.shape();
92
+ break;
93
+ }
94
+ if (typeName === "ZodEffects" || typeName === "ZodOptional" || typeName === "ZodNullable") {
95
+ currentSchema = currentSchema._def.schema || currentSchema._def.innerType;
96
+ continue;
97
+ }
98
+ break;
99
+ }
100
+ const hiddenSet = /* @__PURE__ */ new Set([...DEFAULT_HIDDEN_FIELDS, ...hiddenFields]);
101
+ const fields = [];
102
+ for (const [name, zodType] of Object.entries(shape)) {
103
+ if (hiddenSet.has(name)) {
104
+ continue;
105
+ }
106
+ const fieldType = getFieldType(zodType);
107
+ const required = !isOptional(zodType);
108
+ const editable = !NON_EDITABLE_FIELDS.has(name);
109
+ const enumValues = getEnumValues(zodType);
110
+ let showInTable = true;
111
+ if (tableColumns) {
112
+ showInTable = tableColumns.includes(name);
113
+ } else {
114
+ showInTable = fieldType !== "json";
115
+ }
116
+ const baseConfig = {
117
+ name,
118
+ type: fieldType,
119
+ label: toLabel(name),
120
+ required,
121
+ editable,
122
+ showInTable,
123
+ sortable: fieldType !== "json" && fieldType !== "relation",
124
+ enumValues
125
+ };
126
+ const override = fieldOverrides[name];
127
+ if (override) {
128
+ Object.assign(baseConfig, override);
129
+ }
130
+ fields.push(baseConfig);
131
+ }
132
+ return fields;
133
+ }
134
+ function getDefaultEntityFields() {
135
+ return [
136
+ {
137
+ name: "id",
138
+ type: "string",
139
+ label: "ID",
140
+ required: true,
141
+ editable: false,
142
+ showInTable: true,
143
+ sortable: true
144
+ },
145
+ {
146
+ name: "createdAt",
147
+ type: "date",
148
+ label: "Created At",
149
+ required: true,
150
+ editable: false,
151
+ showInTable: true,
152
+ sortable: true
153
+ },
154
+ {
155
+ name: "updatedAt",
156
+ type: "date",
157
+ label: "Updated At",
158
+ required: true,
159
+ editable: false,
160
+ showInTable: false,
161
+ sortable: true
162
+ }
163
+ ];
164
+ }
165
+ function mergeWithDefaultFields(schemaFields, options = {}) {
166
+ const { includeDefaults = ["id", "createdAt"] } = options;
167
+ const defaultFields = getDefaultEntityFields().filter(
168
+ (f) => includeDefaults.includes(f.name)
169
+ );
170
+ const defaultNames = new Set(defaultFields.map((f) => f.name));
171
+ const filteredSchemaFields = schemaFields.filter(
172
+ (f) => !defaultNames.has(f.name)
173
+ );
174
+ return [...defaultFields, ...filteredSchemaFields];
175
+ }
176
+
15
177
  // src/server/BaseService.ts
16
178
  var BaseService = class {
17
179
  serviceName;
@@ -22,8 +184,12 @@ var BaseService = class {
22
184
  subscribers = /* @__PURE__ */ new Map();
23
185
  // Prisma delegate for DB operations
24
186
  delegate;
187
+ // Socket.io server instance for room-based broadcasts
188
+ io = null;
25
189
  // Collection of public methods for registry discovery
26
190
  publicMethods = /* @__PURE__ */ new Map();
191
+ // Admin metadata configuration (set by installAdminMethods)
192
+ adminMeta = null;
27
193
  constructor(options) {
28
194
  this.serviceName = options.serviceName;
29
195
  this.hasEntryACL = options.hasEntryACL ?? false;
@@ -37,6 +203,13 @@ var BaseService = class {
37
203
  setDelegate(delegate) {
38
204
  this.delegate = delegate;
39
205
  }
206
+ /**
207
+ * Set the Socket.io server instance for room-based broadcasts.
208
+ * Called automatically by ServiceRegistry during registration.
209
+ */
210
+ setIo(io) {
211
+ this.io = io;
212
+ }
40
213
  /**
41
214
  * Get the delegate, throwing if not set.
42
215
  */
@@ -51,9 +224,17 @@ var BaseService = class {
51
224
  // ===========================================================================
52
225
  // Subscription Management
53
226
  // ===========================================================================
227
+ /**
228
+ * Get the Socket.io room name for an entry.
229
+ * Used for room-based broadcasting across services.
230
+ */
231
+ getRoomName(entryId) {
232
+ return `${this.serviceName}:${entryId}`;
233
+ }
54
234
  /**
55
235
  * Subscribe a socket to an entity's updates.
56
236
  * Returns the current entity data if access is granted, null otherwise.
237
+ * Also joins the socket to the entity's Socket.io room for cross-service events.
57
238
  */
58
239
  async subscribe(entryId, socket, requiredLevel = "Read") {
59
240
  if (!socket.userId) {
@@ -72,9 +253,14 @@ var BaseService = class {
72
253
  this.subscribers.set(entryId, /* @__PURE__ */ new Set());
73
254
  }
74
255
  this.subscribers.get(entryId).add(socket);
75
- this.logger.debug(`User ${socket.userId} subscribed to ${entryId}`);
256
+ const roomName = this.getRoomName(entryId);
257
+ void socket.join(roomName);
258
+ this.logger.debug(
259
+ `User ${socket.userId} subscribed to ${entryId} (room: ${roomName})`
260
+ );
76
261
  const entity = await this.findById(entryId);
77
- return entity;
262
+ if (!entity) return null;
263
+ return this.filterEntityForSubscriber(entity, socket, entryId);
78
264
  }
79
265
  /**
80
266
  * Unsubscribe a socket from an entity's updates.
@@ -87,6 +273,8 @@ var BaseService = class {
87
273
  this.subscribers.delete(entryId);
88
274
  }
89
275
  }
276
+ const roomName = this.getRoomName(entryId);
277
+ void socket.leave(roomName);
90
278
  }
91
279
  /**
92
280
  * Remove a socket from all subscriptions (called on disconnect).
@@ -95,22 +283,74 @@ var BaseService = class {
95
283
  for (const [entryId, sockets] of this.subscribers.entries()) {
96
284
  if (sockets.has(socket)) {
97
285
  sockets.delete(socket);
286
+ const roomName = this.getRoomName(entryId);
287
+ void socket.leave(roomName);
98
288
  if (sockets.size === 0) {
99
289
  this.subscribers.delete(entryId);
100
290
  }
101
291
  }
102
292
  }
103
293
  }
294
+ /**
295
+ * Emit a custom event to all subscribers of an entry via Socket.io rooms.
296
+ * This is useful for cross-service events (e.g., message service notifying chat subscribers).
297
+ *
298
+ * @param roomName - The Socket.io room name (use getRoomName for service rooms)
299
+ * @param eventName - The event name to emit
300
+ * @param data - The data to emit
301
+ *
302
+ * @example
303
+ * ```typescript
304
+ * // In MessageService, notify chat subscribers of a new message
305
+ * this.emitToRoom(
306
+ * `chatService:${message.chatId}`,
307
+ * 'chat:message',
308
+ * messageDTO
309
+ * );
310
+ * ```
311
+ */
312
+ emitToRoom(roomName, eventName, data) {
313
+ if (this.io) {
314
+ this.io.to(roomName).emit(eventName, data);
315
+ this.logger.debug(`Emitted ${eventName} to room ${roomName}`);
316
+ return;
317
+ }
318
+ const anySocket = this.getAnySubscriberSocket();
319
+ if (!anySocket) {
320
+ this.logger.debug(`No sockets available to emit to room ${roomName}`);
321
+ return;
322
+ }
323
+ anySocket.to(roomName).emit(eventName, data);
324
+ this.logger.debug(
325
+ `Emitted ${eventName} to room ${roomName} (via socket fallback)`
326
+ );
327
+ }
328
+ /**
329
+ * Get any active socket for room-based operations.
330
+ */
331
+ getAnySubscriberSocket() {
332
+ for (const sockets of this.subscribers.values()) {
333
+ const first = sockets.values().next();
334
+ if (!first.done) {
335
+ return first.value;
336
+ }
337
+ }
338
+ return null;
339
+ }
104
340
  /**
105
341
  * Emit an update to all subscribers of an entity.
342
+ * Uses tier-based pre-filtering: computes filtered versions once,
343
+ * then selects the appropriate version per subscriber.
106
344
  */
107
345
  emitUpdate(entryId, data) {
108
- const eventName = `${this.serviceName}:update:${entryId}`;
109
346
  const subs = this.subscribers.get(entryId);
110
- if (subs) {
111
- for (const socket of subs) {
112
- socket.emit(eventName, data);
113
- }
347
+ if (!subs || subs.size === 0) return;
348
+ const eventName = `${this.serviceName}:update:${entryId}`;
349
+ const fullData = data;
350
+ const publicData = this.stripProtectedFields(data);
351
+ for (const socket of subs) {
352
+ const payload = this.hasElevatedAccess(socket, entryId) ? fullData : publicData;
353
+ socket.emit(eventName, payload);
114
354
  }
115
355
  }
116
356
  // ===========================================================================
@@ -206,6 +446,72 @@ var BaseService = class {
206
446
  throw new Error("Insufficient permissions");
207
447
  }
208
448
  // ===========================================================================
449
+ // Protected Fields Filtering
450
+ // ===========================================================================
451
+ /**
452
+ * Get the list of fields that should be stripped for non-elevated subscribers.
453
+ * Override in derived classes to customize which fields are protected.
454
+ *
455
+ * @example
456
+ * ```typescript
457
+ * protected override getProtectedFields(): (keyof User)[] {
458
+ * return ['email', 'serviceAccess', 'discordId'];
459
+ * }
460
+ * ```
461
+ */
462
+ getProtectedFields() {
463
+ return ["email", "serviceAccess"];
464
+ }
465
+ /**
466
+ * Check if a socket has elevated access to an entity (receives full data).
467
+ * Default: owner (socket.userId === entryId) or service-level Admin.
468
+ * Override in derived classes for custom logic.
469
+ *
470
+ * @example
471
+ * ```typescript
472
+ * protected override hasElevatedAccess(socket: QuickdrawSocket, entryId: string): boolean {
473
+ * // Friends can see more data
474
+ * return super.hasElevatedAccess(socket, entryId) ||
475
+ * this.isFriend(socket.userId, entryId);
476
+ * }
477
+ * ```
478
+ */
479
+ hasElevatedAccess(socket, entryId) {
480
+ return socket.userId === entryId || this.hasServiceAccess(socket, "Admin");
481
+ }
482
+ /**
483
+ * Strip protected fields from an entity.
484
+ * Used internally by filterEntityForSubscriber.
485
+ */
486
+ stripProtectedFields(entity) {
487
+ const protectedFields = this.getProtectedFields();
488
+ const result = { ...entity };
489
+ for (const field of protectedFields) {
490
+ delete result[field];
491
+ }
492
+ return result;
493
+ }
494
+ /**
495
+ * Filter entity data based on subscriber's access level.
496
+ * Override for complex filtering logic (e.g., multiple tiers).
497
+ *
498
+ * @example
499
+ * ```typescript
500
+ * protected override filterEntityForSubscriber(
501
+ * entity: Partial<TEntity>,
502
+ * socket: QuickdrawSocket,
503
+ * entryId: string
504
+ * ): Partial<TEntity> {
505
+ * if (this.hasElevatedAccess(socket, entryId)) return entity;
506
+ * if (this.isFriend(socket.userId, entryId)) return this.stripForFriends(entity);
507
+ * return this.stripProtectedFields(entity);
508
+ * }
509
+ * ```
510
+ */
511
+ filterEntityForSubscriber(entity, socket, entryId) {
512
+ return this.hasElevatedAccess(socket, entryId) ? entity : this.stripProtectedFields(entity);
513
+ }
514
+ // ===========================================================================
209
515
  // CRUD Operations
210
516
  // ===========================================================================
211
517
  /**
@@ -285,14 +591,309 @@ var BaseService = class {
285
591
  return Array.from(this.publicMethods.values());
286
592
  }
287
593
  // ===========================================================================
288
- // Admin Methods (Scaffolding)
594
+ // Admin Methods
289
595
  // ===========================================================================
290
596
  /**
291
597
  * Install standard admin CRUD methods.
292
598
  * Call this in derived class constructor to expose admin endpoints.
599
+ *
600
+ * @example
601
+ * ```typescript
602
+ * this.installAdminMethods({
603
+ * expose: { list: true, get: true, create: true, update: true, delete: true },
604
+ * access: {
605
+ * list: "Admin",
606
+ * get: "Admin",
607
+ * create: "Admin",
608
+ * update: "Admin",
609
+ * delete: "Admin",
610
+ * setEntryACL: "Admin",
611
+ * getSubscribers: "Admin",
612
+ * reemit: "Admin",
613
+ * unsubscribeAll: "Admin",
614
+ * },
615
+ * schema: createEntitySchema,
616
+ * displayName: "Entities",
617
+ * });
618
+ * ```
619
+ */
620
+ installAdminMethods(options) {
621
+ const {
622
+ expose,
623
+ access,
624
+ schema,
625
+ displayName,
626
+ tableColumns,
627
+ hiddenFields,
628
+ fieldOverrides
629
+ } = options;
630
+ if (schema) {
631
+ const schemaFields = zodToAdminFields(schema, {
632
+ hiddenFields,
633
+ tableColumns,
634
+ fieldOverrides
635
+ });
636
+ const fields = mergeWithDefaultFields(schemaFields);
637
+ this.adminMeta = {
638
+ serviceName: this.serviceName,
639
+ displayName: displayName ?? this.toDisplayName(this.serviceName),
640
+ fields
641
+ };
642
+ } else {
643
+ this.adminMeta = {
644
+ serviceName: this.serviceName,
645
+ displayName: displayName ?? this.toDisplayName(this.serviceName),
646
+ fields: []
647
+ };
648
+ }
649
+ const shouldExposeMeta = expose.meta ?? (expose.list || expose.get || expose.create || expose.update || expose.delete);
650
+ if (shouldExposeMeta) {
651
+ this.publicMethods.set("adminMeta", {
652
+ name: "adminMeta",
653
+ access: access.meta ?? access.list,
654
+ handler: async (_payload, _context) => {
655
+ return this.getAdminMeta();
656
+ }
657
+ });
658
+ }
659
+ if (expose.list) {
660
+ this.publicMethods.set("adminList", {
661
+ name: "adminList",
662
+ access: access.list,
663
+ handler: async (payload, _context) => {
664
+ return await this.adminList(payload);
665
+ }
666
+ });
667
+ }
668
+ if (expose.get) {
669
+ this.publicMethods.set("adminGet", {
670
+ name: "adminGet",
671
+ access: access.get,
672
+ handler: async (payload, _context) => {
673
+ return await this.adminGet(payload.id);
674
+ },
675
+ resolveEntryId: (payload) => payload.id
676
+ });
677
+ }
678
+ if (expose.create) {
679
+ this.publicMethods.set("adminCreate", {
680
+ name: "adminCreate",
681
+ access: access.create,
682
+ handler: async (payload, _context) => {
683
+ return await this.adminCreate(
684
+ payload.data
685
+ );
686
+ }
687
+ });
688
+ }
689
+ if (expose.update) {
690
+ this.publicMethods.set("adminUpdate", {
691
+ name: "adminUpdate",
692
+ access: access.update,
693
+ handler: async (payload, _context) => {
694
+ const { id, data } = payload;
695
+ return await this.adminUpdate(id, data);
696
+ },
697
+ resolveEntryId: (payload) => payload.id
698
+ });
699
+ }
700
+ if (expose.delete) {
701
+ this.publicMethods.set("adminDelete", {
702
+ name: "adminDelete",
703
+ access: access.delete,
704
+ handler: async (payload, _context) => {
705
+ return await this.adminDelete(payload.id);
706
+ },
707
+ resolveEntryId: (payload) => payload.id
708
+ });
709
+ }
710
+ if (expose.setEntryACL) {
711
+ this.publicMethods.set("adminSetEntryACL", {
712
+ name: "adminSetEntryACL",
713
+ access: access.setEntryACL,
714
+ handler: async (payload, _context) => {
715
+ return await this.adminSetEntryACL(payload);
716
+ },
717
+ resolveEntryId: (payload) => payload.entryId
718
+ });
719
+ }
720
+ if (expose.getSubscribers) {
721
+ this.publicMethods.set("adminGetSubscribers", {
722
+ name: "adminGetSubscribers",
723
+ access: access.getSubscribers,
724
+ handler: async (payload, _context) => {
725
+ return this.adminGetSubscribers(
726
+ payload.entryId
727
+ );
728
+ },
729
+ resolveEntryId: (payload) => payload.entryId
730
+ });
731
+ }
732
+ if (expose.reemit) {
733
+ this.publicMethods.set("adminReemit", {
734
+ name: "adminReemit",
735
+ access: access.reemit,
736
+ handler: async (payload, _context) => {
737
+ return await this.adminReemit(
738
+ payload.entryId
739
+ );
740
+ },
741
+ resolveEntryId: (payload) => payload.entryId
742
+ });
743
+ }
744
+ if (expose.unsubscribeAll) {
745
+ this.publicMethods.set("adminUnsubscribeAll", {
746
+ name: "adminUnsubscribeAll",
747
+ access: access.unsubscribeAll,
748
+ handler: async (payload, _context) => {
749
+ return this.adminUnsubscribeAll(
750
+ payload.entryId
751
+ );
752
+ },
753
+ resolveEntryId: (payload) => payload.entryId
754
+ });
755
+ }
756
+ const installedMethods = Object.keys(expose).filter(
757
+ (k) => expose[k]
758
+ );
759
+ if (shouldExposeMeta) installedMethods.push("meta");
760
+ this.logger.info(`Installed admin methods: ${installedMethods.join(", ")}`);
761
+ }
762
+ /**
763
+ * Convert service name to display name.
764
+ * e.g., "chatService" -> "Chats", "userService" -> "Users"
293
765
  */
294
- installAdminMethods(_options) {
295
- this.logger.debug("Admin methods installation placeholder");
766
+ toDisplayName(serviceName) {
767
+ return serviceName.replace(/Service$/i, "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()) + "s";
768
+ }
769
+ /**
770
+ * Get admin metadata for this service.
771
+ * Returns the configured metadata or throws if not configured.
772
+ */
773
+ getAdminMeta() {
774
+ if (!this.adminMeta) {
775
+ throw new Error(
776
+ `Admin methods not installed for ${this.serviceName}. Call installAdminMethods() first.`
777
+ );
778
+ }
779
+ return this.adminMeta;
780
+ }
781
+ /**
782
+ * Admin method: List entities with pagination and filters.
783
+ */
784
+ async adminList(payload) {
785
+ const { page = 1, pageSize = 20, where, orderBy } = payload;
786
+ const skip = (page - 1) * pageSize;
787
+ const [items, total] = await Promise.all([
788
+ this.getDelegate().findMany({
789
+ where,
790
+ orderBy,
791
+ skip,
792
+ take: pageSize
793
+ }),
794
+ this.getDelegate().count({ where })
795
+ ]);
796
+ return {
797
+ items,
798
+ total,
799
+ page,
800
+ pageSize,
801
+ totalPages: Math.ceil(total / pageSize)
802
+ };
803
+ }
804
+ /**
805
+ * Admin method: Get a single entity by ID.
806
+ */
807
+ async adminGet(id) {
808
+ return await this.findById(id);
809
+ }
810
+ /**
811
+ * Admin method: Create an entity.
812
+ */
813
+ async adminCreate(data) {
814
+ return await this.create(data);
815
+ }
816
+ /**
817
+ * Admin method: Update an entity.
818
+ */
819
+ async adminUpdate(id, data) {
820
+ return await this.update(id, data);
821
+ }
822
+ /**
823
+ * Admin method: Delete an entity.
824
+ */
825
+ async adminDelete(id) {
826
+ const deleted = await this.delete(id);
827
+ return { success: deleted, id };
828
+ }
829
+ /**
830
+ * Admin method: Set entry-level ACL.
831
+ */
832
+ async adminSetEntryACL(payload) {
833
+ const { entryId, acl } = payload;
834
+ try {
835
+ const entity = await this.getDelegate().update({
836
+ where: { id: entryId },
837
+ data: { acl }
838
+ });
839
+ this.emitUpdate(entryId, entity);
840
+ this.logger.info(`Updated ACL for entity ${entryId}`);
841
+ return entity;
842
+ } catch (error) {
843
+ this.logger.error(`Failed to set ACL for ${entryId}`, {
844
+ error: error instanceof Error ? error.message : String(error)
845
+ });
846
+ return null;
847
+ }
848
+ }
849
+ /**
850
+ * Admin method: Get active subscribers for an entity.
851
+ */
852
+ adminGetSubscribers(entryId) {
853
+ const subs = this.subscribers.get(entryId);
854
+ if (!subs) {
855
+ return { entryId, subscribers: [], count: 0 };
856
+ }
857
+ const subscribers = Array.from(subs).map((socket) => ({
858
+ socketId: socket.id,
859
+ userId: socket.userId ?? null
860
+ }));
861
+ return {
862
+ entryId,
863
+ subscribers,
864
+ count: subscribers.length
865
+ };
866
+ }
867
+ /**
868
+ * Admin method: Re-emit current entity state to all subscribers.
869
+ * Useful when you've made direct DB changes or need to force-refresh clients.
870
+ */
871
+ async adminReemit(entryId) {
872
+ const entity = await this.findById(entryId);
873
+ if (!entity) {
874
+ return { success: false, subscriberCount: 0 };
875
+ }
876
+ const subs = this.subscribers.get(entryId);
877
+ const count = subs?.size ?? 0;
878
+ this.emitUpdate(entryId, entity);
879
+ this.logger.info(`Re-emitted entity ${entryId} to ${count} subscribers`);
880
+ return { success: true, subscriberCount: count };
881
+ }
882
+ /**
883
+ * Admin method: Unsubscribe all sockets from an entity.
884
+ */
885
+ adminUnsubscribeAll(entryId) {
886
+ const subs = this.subscribers.get(entryId);
887
+ const count = subs?.size ?? 0;
888
+ if (subs) {
889
+ const eventName = `${this.serviceName}:unsubscribed:${entryId}`;
890
+ for (const socket of subs) {
891
+ socket.emit(eventName, { reason: "admin_action" });
892
+ }
893
+ this.subscribers.delete(entryId);
894
+ }
895
+ this.logger.info(`Unsubscribed ${count} sockets from entity ${entryId}`);
896
+ return { success: true, unsubscribedCount: count };
296
897
  }
297
898
  // ===========================================================================
298
899
  // Utility Methods
@@ -310,47 +911,84 @@ var ServiceRegistry = class {
310
911
  io;
311
912
  services = /* @__PURE__ */ new Map();
312
913
  logger;
914
+ methodLoggingEnabled;
915
+ logPayloads;
916
+ logResponses;
917
+ connectionHandlerInstalled = false;
918
+ // Store method metadata for deferred registration
919
+ methodRegistry = /* @__PURE__ */ new Map();
313
920
  constructor(io, options) {
314
921
  this.io = io;
315
922
  this.logger = options?.logger?.child({ service: "ServiceRegistry" }) ?? consoleLogger.child({ service: "ServiceRegistry" });
923
+ this.methodLoggingEnabled = options?.methodLogging?.enabled ?? true;
924
+ this.logPayloads = options?.methodLogging?.logPayloads ?? false;
925
+ this.logResponses = options?.methodLogging?.logResponses ?? false;
926
+ if (this.methodLoggingEnabled) {
927
+ this.logger.info("Automatic method logging enabled", {
928
+ logPayloads: this.logPayloads,
929
+ logResponses: this.logResponses
930
+ });
931
+ }
932
+ }
933
+ /**
934
+ * Install the single connection handler that wires all registered services.
935
+ * This is called lazily on first service registration.
936
+ */
937
+ installConnectionHandler() {
938
+ if (this.connectionHandlerInstalled) return;
939
+ this.connectionHandlerInstalled = true;
940
+ this.io.on("connection", (socket) => {
941
+ const quickdrawSocket = socket;
942
+ this.setupSocketListeners(quickdrawSocket);
943
+ });
944
+ this.logger.info("Installed single connection handler for all services");
945
+ }
946
+ /**
947
+ * Set up all event listeners for a newly connected socket.
948
+ */
949
+ setupSocketListeners(socket) {
950
+ for (const [eventName, { method, service }] of this.methodRegistry) {
951
+ this.registerMethodListener(socket, eventName, method, service);
952
+ }
953
+ for (const [serviceName, service] of this.services) {
954
+ this.registerSubscriptionListener(
955
+ socket,
956
+ `${serviceName}:subscribe`,
957
+ service
958
+ );
959
+ this.registerUnsubscriptionListener(
960
+ socket,
961
+ `${serviceName}:unsubscribe`,
962
+ service
963
+ );
964
+ }
316
965
  }
317
966
  /**
318
967
  * Register a service and auto-discover its public methods.
319
968
  */
320
969
  registerService(serviceName, service) {
321
970
  this.services.set(serviceName, service);
971
+ if (service.setIo) {
972
+ service.setIo(this.io);
973
+ }
322
974
  this.logger.info(`Registered service: ${serviceName}`);
323
975
  this.discoverServiceMethods(serviceName, service);
976
+ this.installConnectionHandler();
324
977
  }
325
978
  /**
326
- * Discover and register all public methods from a service.
979
+ * Discover and store all public methods from a service.
327
980
  */
328
981
  discoverServiceMethods(serviceName, service) {
329
982
  const methods = service.getPublicMethods();
330
983
  for (const method of methods) {
331
984
  const eventName = `${serviceName}:${method.name}`;
332
985
  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
- );
986
+ this.methodRegistry.set(eventName, {
987
+ serviceName,
988
+ method,
989
+ service
340
990
  });
341
991
  }
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
992
  }
355
993
  /**
356
994
  * Register a method as a socket event listener.
@@ -396,33 +1034,45 @@ var ServiceRegistry = class {
396
1034
  }
397
1035
  }
398
1036
  await service.ensureAccessForMethod(method.access, socket, entryId);
399
- this.logger.info(
400
- `User ${userIdShort} ${serviceName}.${methodName} -> start`,
401
- {
1037
+ if (this.methodLoggingEnabled) {
1038
+ const logContext = {
402
1039
  category: "request_processing",
403
1040
  serviceName,
404
1041
  methodName,
405
1042
  userId: socket.userId,
406
1043
  socketId: socket.id
1044
+ };
1045
+ if (this.logPayloads) {
1046
+ logContext.payload = validatedPayload;
407
1047
  }
408
- );
1048
+ this.logger.info(
1049
+ `User ${userIdShort} ${serviceName}.${methodName} -> start`,
1050
+ logContext
1051
+ );
1052
+ }
409
1053
  const result = await method.handler(validatedPayload, {
410
1054
  userId: socket.userId,
411
1055
  socketId: socket.id,
412
1056
  serviceAccess: socket.serviceAccess ?? {}
413
1057
  });
414
- const durationMs = Date.now() - startedAt;
415
- this.logger.info(
416
- `User ${userIdShort} ${serviceName}.${methodName} -> success`,
417
- {
1058
+ if (this.methodLoggingEnabled) {
1059
+ const durationMs = Date.now() - startedAt;
1060
+ const logContext = {
418
1061
  category: "request_processing",
419
1062
  outcome: "success",
420
1063
  durationMs,
421
1064
  serviceName,
422
1065
  methodName,
423
1066
  userId: socket.userId
1067
+ };
1068
+ if (this.logResponses) {
1069
+ logContext.response = result;
424
1070
  }
425
- );
1071
+ this.logger.info(
1072
+ `User ${userIdShort} ${serviceName}.${methodName} -> success`,
1073
+ logContext
1074
+ );
1075
+ }
426
1076
  const successResponse = {
427
1077
  success: true,
428
1078
  data: result
@@ -430,14 +1080,22 @@ var ServiceRegistry = class {
430
1080
  callback?.(successResponse);
431
1081
  } catch (error) {
432
1082
  const durationMs = Date.now() - startedAt;
1083
+ const logContext = {
1084
+ category: "request_processing",
1085
+ outcome: "fail",
1086
+ durationMs,
1087
+ serviceName,
1088
+ methodName,
1089
+ userId: socket.userId,
1090
+ socketId: socket.id,
1091
+ error: error instanceof Error ? error.message : "Unknown error"
1092
+ };
1093
+ if (this.logPayloads) {
1094
+ logContext.payload = payload;
1095
+ }
433
1096
  this.logger.error(
434
1097
  `User ${userIdShort} ${serviceName}.${methodName} -> fail`,
435
- {
436
- category: "request_processing",
437
- outcome: "fail",
438
- durationMs,
439
- error: error instanceof Error ? error.message : "Unknown error"
440
- }
1098
+ logContext
441
1099
  );
442
1100
  const errorResponse = {
443
1101
  success: false,
@@ -545,7 +1203,10 @@ function createQuickdrawServer(options) {
545
1203
  credentials: options.cors?.credentials ?? true
546
1204
  }
547
1205
  });
548
- const registry = new ServiceRegistry(io, { logger });
1206
+ const registry = new ServiceRegistry(io, {
1207
+ logger,
1208
+ methodLogging: options.methodLogging
1209
+ });
549
1210
  io.use(async (socket, next) => {
550
1211
  const quickdrawSocket = socket;
551
1212
  try {
@@ -592,8 +1253,246 @@ function createQuickdrawServer(options) {
592
1253
  `Registered services: ${registry.getServices().join(", ")}`
593
1254
  );
594
1255
  });
1256
+ const shutdown = async (signal) => {
1257
+ serverLogger.info(`Received ${signal}, starting graceful shutdown...`);
1258
+ httpServer.close(() => {
1259
+ serverLogger.info("HTTP server closed");
1260
+ });
1261
+ const sockets = await io.fetchSockets();
1262
+ serverLogger.info(`Closing ${sockets.length} active socket connections...`);
1263
+ for (const socket of sockets) {
1264
+ const quickdrawSocket = socket;
1265
+ for (const service of registry.getServiceInstances()) {
1266
+ try {
1267
+ service.unsubscribeSocket(quickdrawSocket);
1268
+ } catch {
1269
+ }
1270
+ }
1271
+ socket.disconnect(true);
1272
+ }
1273
+ io.close(() => {
1274
+ serverLogger.info("Socket.io server closed");
1275
+ process.exit(0);
1276
+ });
1277
+ setTimeout(() => {
1278
+ serverLogger.error("Graceful shutdown timeout, forcing exit");
1279
+ process.exit(1);
1280
+ }, 1e4);
1281
+ };
1282
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1283
+ process.on("SIGINT", () => shutdown("SIGINT"));
595
1284
  return { io, httpServer, registry };
596
1285
  }
1286
+
1287
+ // src/server/redis.ts
1288
+ async function setupRedisAdapter(io, options = {}) {
1289
+ const logger = options.logger?.child({ service: "RedisAdapter" }) ?? consoleLogger.child({ service: "RedisAdapter" });
1290
+ const {
1291
+ host = "localhost",
1292
+ port = 6379,
1293
+ password,
1294
+ db = 0,
1295
+ keyPrefix = "socket.io"
1296
+ } = options;
1297
+ try {
1298
+ const dynamicImport = new Function("specifier", "return import(specifier)");
1299
+ const [redisAdapterModule, redisModule] = await Promise.all([
1300
+ dynamicImport("@socket.io/redis-adapter"),
1301
+ dynamicImport("redis")
1302
+ ]);
1303
+ const createAdapter = redisAdapterModule.createAdapter;
1304
+ const createClient = redisModule.createClient;
1305
+ const pubClient = createClient({
1306
+ socket: { host, port },
1307
+ password,
1308
+ database: db
1309
+ });
1310
+ const subClient = pubClient.duplicate();
1311
+ await Promise.all([pubClient.connect(), subClient.connect()]);
1312
+ io.adapter(createAdapter(pubClient, subClient, { key: keyPrefix }));
1313
+ logger.info(`Redis adapter connected to ${host}:${port}`);
1314
+ const cleanup = async () => {
1315
+ try {
1316
+ await Promise.all([pubClient.quit(), subClient.quit()]);
1317
+ logger.info("Redis adapter disconnected");
1318
+ } catch (error) {
1319
+ logger.error("Error disconnecting Redis adapter", {
1320
+ error: error instanceof Error ? error.message : String(error)
1321
+ });
1322
+ }
1323
+ };
1324
+ return { success: true, cleanup };
1325
+ } catch (error) {
1326
+ const errorMessage = error instanceof Error ? error.message : String(error);
1327
+ if (errorMessage.includes("Cannot find module") || errorMessage.includes("MODULE_NOT_FOUND")) {
1328
+ logger.warn(
1329
+ "Redis adapter packages not installed. Install @socket.io/redis-adapter and redis for horizontal scaling support."
1330
+ );
1331
+ } else {
1332
+ logger.error("Failed to set up Redis adapter", { error: errorMessage });
1333
+ }
1334
+ return {
1335
+ success: false,
1336
+ cleanup: async () => {
1337
+ }
1338
+ };
1339
+ }
1340
+ }
1341
+ async function isRedisAdapterAvailable() {
1342
+ try {
1343
+ const dynamicImport = new Function("specifier", "return import(specifier)");
1344
+ await Promise.all([
1345
+ dynamicImport("@socket.io/redis-adapter"),
1346
+ dynamicImport("redis")
1347
+ ]);
1348
+ return true;
1349
+ } catch {
1350
+ return false;
1351
+ }
1352
+ }
1353
+
1354
+ // src/server/rateLimit.ts
1355
+ function createRateLimiter(options = {}) {
1356
+ const {
1357
+ windowMs = 6e4,
1358
+ maxRequests = 100,
1359
+ excludeEvents = []
1360
+ } = options;
1361
+ const entries = /* @__PURE__ */ new Map();
1362
+ const cleanupInterval = setInterval(() => {
1363
+ const now = Date.now();
1364
+ for (const [key, entry] of entries) {
1365
+ if (entry.resetAt <= now) {
1366
+ entries.delete(key);
1367
+ }
1368
+ }
1369
+ }, windowMs);
1370
+ if (cleanupInterval.unref) {
1371
+ cleanupInterval.unref();
1372
+ }
1373
+ return {
1374
+ check(key) {
1375
+ const now = Date.now();
1376
+ const entry = entries.get(key);
1377
+ if (!entry || entry.resetAt <= now) {
1378
+ entries.set(key, {
1379
+ count: 1,
1380
+ resetAt: now + windowMs
1381
+ });
1382
+ return true;
1383
+ }
1384
+ if (entry.count >= maxRequests) {
1385
+ return false;
1386
+ }
1387
+ entry.count++;
1388
+ return true;
1389
+ },
1390
+ getRemaining(key) {
1391
+ const now = Date.now();
1392
+ const entry = entries.get(key);
1393
+ if (!entry || entry.resetAt <= now) {
1394
+ return maxRequests;
1395
+ }
1396
+ return Math.max(0, maxRequests - entry.count);
1397
+ },
1398
+ getResetTime(key) {
1399
+ const now = Date.now();
1400
+ const entry = entries.get(key);
1401
+ if (!entry || entry.resetAt <= now) {
1402
+ return 0;
1403
+ }
1404
+ return entry.resetAt - now;
1405
+ },
1406
+ clear() {
1407
+ entries.clear();
1408
+ },
1409
+ options: {
1410
+ windowMs,
1411
+ maxRequests,
1412
+ excludeEvents
1413
+ }
1414
+ };
1415
+ }
1416
+ function applyRateLimitMiddleware(io, rateLimiter, options = {}) {
1417
+ const logger = options.logger?.child({ service: "RateLimiter" }) ?? consoleLogger.child({ service: "RateLimiter" });
1418
+ const keyGenerator = options.keyGenerator ?? ((socket) => socket.id);
1419
+ const onRateLimitExceeded = options.onRateLimitExceeded ?? ((socket, eventName) => {
1420
+ socket.emit("error", {
1421
+ code: "RATE_LIMITED",
1422
+ message: `Rate limit exceeded for event: ${eventName}`,
1423
+ retryAfter: rateLimiter.getResetTime(keyGenerator(socket, eventName))
1424
+ });
1425
+ });
1426
+ const { excludeEvents } = rateLimiter.options;
1427
+ io.on("connection", (socket) => {
1428
+ socket.use(([eventName, ...args], next) => {
1429
+ if (excludeEvents.includes(eventName)) {
1430
+ next();
1431
+ return;
1432
+ }
1433
+ if (eventName.startsWith("$")) {
1434
+ next();
1435
+ return;
1436
+ }
1437
+ const key = keyGenerator(socket, eventName);
1438
+ if (rateLimiter.check(key)) {
1439
+ next();
1440
+ } else {
1441
+ logger.warn(`Rate limit exceeded for socket ${socket.id} on event ${eventName}`);
1442
+ onRateLimitExceeded(socket, eventName);
1443
+ const lastArg = args[args.length - 1];
1444
+ if (typeof lastArg === "function") {
1445
+ lastArg({
1446
+ success: false,
1447
+ error: "Rate limit exceeded",
1448
+ code: 429
1449
+ });
1450
+ }
1451
+ }
1452
+ });
1453
+ });
1454
+ logger.info(
1455
+ `Rate limiting enabled: ${rateLimiter.options.maxRequests} requests per ${rateLimiter.options.windowMs}ms`
1456
+ );
1457
+ }
1458
+ function createTieredRateLimiter(tiers, defaultOptions = {}) {
1459
+ const tierLimiters = /* @__PURE__ */ new Map();
1460
+ const tierPatterns = [];
1461
+ for (const [pattern, options] of Object.entries(tiers)) {
1462
+ const limiter = createRateLimiter({ ...defaultOptions, ...options });
1463
+ tierLimiters.set(pattern, limiter);
1464
+ const regexPattern = pattern.replace(/\*/g, ".*").replace(/\?/g, ".");
1465
+ tierPatterns.push({
1466
+ pattern: new RegExp(`^${regexPattern}$`),
1467
+ limiter
1468
+ });
1469
+ }
1470
+ const defaultLimiter = createRateLimiter(defaultOptions);
1471
+ function getLimiterForEvent(eventName) {
1472
+ for (const { pattern, limiter } of tierPatterns) {
1473
+ if (pattern.test(eventName)) {
1474
+ return limiter;
1475
+ }
1476
+ }
1477
+ return defaultLimiter;
1478
+ }
1479
+ return {
1480
+ check(key, eventName) {
1481
+ const limiter = getLimiterForEvent(eventName);
1482
+ return limiter.check(`${key}:${eventName}`);
1483
+ },
1484
+ getRemaining(key, eventName) {
1485
+ const limiter = getLimiterForEvent(eventName);
1486
+ return limiter.getRemaining(`${key}:${eventName}`);
1487
+ },
1488
+ clear() {
1489
+ for (const limiter of tierLimiters.values()) {
1490
+ limiter.clear();
1491
+ }
1492
+ defaultLimiter.clear();
1493
+ }
1494
+ };
1495
+ }
597
1496
  var DEFAULT_EXPIRATION = "7d";
598
1497
  async function createJWT(payload, secret, expiresIn = DEFAULT_EXPIRATION) {
599
1498
  const secretKey = new TextEncoder().encode(secret);
@@ -702,6 +1601,45 @@ var googleProvider = {
702
1601
  }
703
1602
  };
704
1603
 
705
- export { BaseService, ServiceRegistry, createJWT, createOAuthURL, createQuickdrawServer, discordProvider, exchangeOAuthCode, googleProvider, verifyJWT };
1604
+ // src/server/utils/env.ts
1605
+ function validateEnv(options) {
1606
+ const result = checkEnv(options);
1607
+ if (!result.success) {
1608
+ throw new Error(result.message);
1609
+ }
1610
+ }
1611
+ function checkEnv(options) {
1612
+ const { required, productionOnly = true, errorPrefix = "Missing required environment variables" } = options;
1613
+ if (productionOnly && process.env.NODE_ENV !== "production") {
1614
+ return { success: true, missing: [] };
1615
+ }
1616
+ const missing = [];
1617
+ for (const varName of required) {
1618
+ const value = process.env[varName];
1619
+ if (!value || value.trim() === "") {
1620
+ missing.push(varName);
1621
+ }
1622
+ }
1623
+ if (missing.length > 0) {
1624
+ return {
1625
+ success: false,
1626
+ missing,
1627
+ message: `${errorPrefix}: ${missing.join(", ")}`
1628
+ };
1629
+ }
1630
+ return { success: true, missing: [] };
1631
+ }
1632
+ function requireEnv(name, defaultValue) {
1633
+ const value = process.env[name];
1634
+ if (!value || value.trim() === "") {
1635
+ if (defaultValue !== void 0) {
1636
+ return defaultValue;
1637
+ }
1638
+ throw new Error(`Environment variable ${name} is required but not set`);
1639
+ }
1640
+ return value;
1641
+ }
1642
+
1643
+ export { BaseService, ServiceRegistry, applyRateLimitMiddleware, checkEnv, createJWT, createOAuthURL, createQuickdrawServer, createRateLimiter, createTieredRateLimiter, discordProvider, exchangeOAuthCode, getDefaultEntityFields, googleProvider, isRedisAdapterAvailable, mergeWithDefaultFields, requireEnv, setupRedisAdapter, validateEnv, verifyJWT, zodToAdminFields };
706
1644
  //# sourceMappingURL=index.js.map
707
1645
  //# sourceMappingURL=index.js.map