@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.
- package/README.md +122 -28
- package/dist/client/index.d.ts +30 -37
- package/dist/client/index.js +103 -58
- 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 +577 -9
- package/dist/server/index.js +985 -47
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing.d.ts +2 -2
- package/dist/server/testing.js +124 -36
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +111 -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-D7oHWh4O.d.ts} +60 -2
- package/package.json +12 -13
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
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.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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.
|
|
400
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|