@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.
- package/README.md +122 -28
- package/dist/client/index.d.ts +30 -37
- package/dist/client/index.js +81 -42
- package/dist/client/index.js.map +1 -1
- package/dist/client/testing.d.ts +1 -1
- package/dist/client/testing.js +32 -0
- package/dist/client/testing.js.map +1 -1
- package/dist/server/index.d.ts +417 -5
- package/dist/server/index.js +643 -31
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server/testing.js +45 -20
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +63 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/{types-CUExGF7F.d.ts → types-BADirRXn.d.ts} +50 -8
- package/dist/{types-OhRxDBID.d.ts → types-CDhCEpTU.d.ts} +31 -1
- package/package.json +12 -13
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
295
|
-
this.
|
|
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
|
|
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.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|