@fitzzero/quickdraw-core 3.7.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/dist/client/index.d.ts +34 -4
- package/dist/client/index.js +25 -4
- package/dist/client/index.js.map +1 -1
- package/dist/client/testing.d.ts +1 -1
- package/dist/server/index.d.ts +103 -4
- package/dist/server/index.js +194 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server/testing.js +82 -0
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +77 -1
- package/dist/shared/index.js +5 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/{types-DniMxkQs.d.ts → types-ClgSr90l.d.ts} +5 -1
- package/dist/{types-B1z5aXdR.d.ts → types-DUHPIy_5.d.ts} +20 -1
- package/package.json +1 -1
package/dist/server/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { a as QuickdrawSocket, P as PrismaDelegate, b as BaseServiceOptions, I as InstallAdminMethodsOptions, A as AdminListPayload, c as AdminListResponse, d as AdminSetACLPayload, e as AdminSubscribersResponse, S as ServiceRegistryInstance, B as BaseServiceInstance, f as QuickdrawServerOptions, Q as QuickdrawServerResult } from '../types-
|
|
1
|
+
import { a as QuickdrawSocket, P as PrismaDelegate, b as BaseServiceOptions, I as InstallAdminMethodsOptions, A as AdminListPayload, c as AdminListResponse, d as AdminSetACLPayload, e as AdminSubscribersResponse, S as ServiceRegistryInstance, B as BaseServiceInstance, f as QuickdrawServerOptions, Q as QuickdrawServerResult } from '../types-ClgSr90l.js';
|
|
2
2
|
import { Server, Socket } from 'socket.io';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { ACL, Logger, AccessLevel, ServiceMethodContext, ServiceMethodDefinition, AdminServiceMeta, AdminFieldConfig } from '../shared/index.js';
|
|
4
|
+
import { ACL, Logger, AccessLevel, ServiceMethodContext, ServiceMethodDefinition, ServiceChannelContext, ServiceChannelDefinition, AdminServiceMeta, AdminFieldConfig } from '../shared/index.js';
|
|
5
5
|
import { Router } from 'express';
|
|
6
6
|
import 'http';
|
|
7
7
|
|
|
@@ -19,6 +19,7 @@ import 'http';
|
|
|
19
19
|
* @typeParam TCreateInput - The create input type
|
|
20
20
|
* @typeParam TUpdateInput - The update input type
|
|
21
21
|
* @typeParam TServiceMethods - Service method definitions map
|
|
22
|
+
* @typeParam TChannels - Service channel payload map (high-frequency fire-and-forget events)
|
|
22
23
|
*
|
|
23
24
|
* @example
|
|
24
25
|
* ```typescript
|
|
@@ -48,7 +49,9 @@ declare abstract class BaseService<TEntity extends {
|
|
|
48
49
|
} = Record<string, {
|
|
49
50
|
payload: unknown;
|
|
50
51
|
response: unknown;
|
|
51
|
-
}
|
|
52
|
+
}>, TChannels extends {
|
|
53
|
+
[K in keyof TChannels]: unknown;
|
|
54
|
+
} = Record<string, unknown>> {
|
|
52
55
|
readonly serviceName: string;
|
|
53
56
|
protected readonly hasEntryACL: boolean;
|
|
54
57
|
protected readonly defaultACL: ACL;
|
|
@@ -57,6 +60,7 @@ declare abstract class BaseService<TEntity extends {
|
|
|
57
60
|
protected delegate: PrismaDelegate<TEntity, TCreateInput, TUpdateInput> | undefined;
|
|
58
61
|
protected io: Server | null;
|
|
59
62
|
private readonly publicMethods;
|
|
63
|
+
private readonly publicChannels;
|
|
60
64
|
private adminMeta;
|
|
61
65
|
constructor(options: BaseServiceOptions);
|
|
62
66
|
/**
|
|
@@ -138,6 +142,28 @@ declare abstract class BaseService<TEntity extends {
|
|
|
138
142
|
* ```
|
|
139
143
|
*/
|
|
140
144
|
emitToRoom(roomName: string, eventName: string, data: unknown): void;
|
|
145
|
+
/**
|
|
146
|
+
* Emit a custom event to a room using Socket.io's volatile flag.
|
|
147
|
+
*
|
|
148
|
+
* Volatile events are dropped for clients whose connection is backpressured
|
|
149
|
+
* instead of being buffered — exactly what you want for high-frequency state
|
|
150
|
+
* that is superseded by the next emit (game snapshots, cursor positions).
|
|
151
|
+
* Never use this for events a client must not miss.
|
|
152
|
+
*
|
|
153
|
+
* Intentionally does no per-call logging: this is a hot path, typically
|
|
154
|
+
* called at tick rate (10–60Hz).
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```typescript
|
|
158
|
+
* // In a 20Hz game loop
|
|
159
|
+
* this.emitToRoomVolatile(
|
|
160
|
+
* this.getRoomName(worldId),
|
|
161
|
+
* 'game:snapshot',
|
|
162
|
+
* snapshot
|
|
163
|
+
* );
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
emitToRoomVolatile(roomName: string, eventName: string, data: unknown): void;
|
|
141
167
|
/**
|
|
142
168
|
* Emit a custom event to a specific user's room.
|
|
143
169
|
* Users join `user:{userId}` on connection (set up in auth middleware).
|
|
@@ -274,6 +300,58 @@ declare abstract class BaseService<TEntity extends {
|
|
|
274
300
|
* Get all public methods for registry discovery.
|
|
275
301
|
*/
|
|
276
302
|
getPublicMethods(): ServiceMethodDefinition<unknown, unknown>[];
|
|
303
|
+
/**
|
|
304
|
+
* Define a channel: a fire-and-forget event for high-frequency traffic
|
|
305
|
+
* (game input, cursor positions, typing indicators).
|
|
306
|
+
*
|
|
307
|
+
* Channels differ from methods:
|
|
308
|
+
* - No ack, no response — the handler returns void and errors are logged,
|
|
309
|
+
* never sent to the client.
|
|
310
|
+
* - Exempt from the global rate limiter; each channel enforces its own
|
|
311
|
+
* per-socket token bucket (`ratePerSecond`/`burst`). Excess messages are
|
|
312
|
+
* silently dropped; sustained extreme abuse disconnects the socket.
|
|
313
|
+
* - Access checks are fully synchronous and in-memory: authentication is
|
|
314
|
+
* always required; "Moderate"/"Admin" check the socket's serviceAccess;
|
|
315
|
+
* entry-level access is expressed via `requireRoom` (the socket must
|
|
316
|
+
* already be in that room, which was ACL-gated at subscribe time).
|
|
317
|
+
* - `schema` is required — channel payloads are untrusted input arriving
|
|
318
|
+
* at tick rate.
|
|
319
|
+
*
|
|
320
|
+
* Routes as the Socket.io event `channel:<serviceName>:<name>`.
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* ```typescript
|
|
324
|
+
* this.defineChannel(
|
|
325
|
+
* "input",
|
|
326
|
+
* "Read",
|
|
327
|
+
* (payload, ctx) => this.sim.applyInput(ctx.userId, payload),
|
|
328
|
+
* {
|
|
329
|
+
* schema: gameInputSchema,
|
|
330
|
+
* ratePerSecond: 30,
|
|
331
|
+
* requireRoom: () => this.getRoomName(GLOBAL_WORLD_ID),
|
|
332
|
+
* },
|
|
333
|
+
* );
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
defineChannel<K extends keyof TChannels & string>(name: K, access: AccessLevel, handler: (payload: TChannels[K], context: ServiceChannelContext) => void, options: {
|
|
337
|
+
schema: z.ZodType<TChannels[K]>;
|
|
338
|
+
/** Sustained messages/second per socket. Default: 30 */
|
|
339
|
+
ratePerSecond?: number;
|
|
340
|
+
/** Bucket capacity for bursts. Default: 2 × ratePerSecond */
|
|
341
|
+
burst?: number;
|
|
342
|
+
/** Room the socket must already be in (null result skips the check). */
|
|
343
|
+
requireRoom?: (payload: TChannels[K]) => string | null;
|
|
344
|
+
}): ServiceChannelDefinition<TChannels[K]>;
|
|
345
|
+
/**
|
|
346
|
+
* Get all channels for registry discovery.
|
|
347
|
+
*/
|
|
348
|
+
getPublicChannels(): ServiceChannelDefinition<unknown>[];
|
|
349
|
+
/**
|
|
350
|
+
* Synchronous, in-memory access check for a channel message.
|
|
351
|
+
* Called by the registry on the hot path — must not touch the database.
|
|
352
|
+
* Returns false to silently drop the message.
|
|
353
|
+
*/
|
|
354
|
+
checkChannelAccess(channel: ServiceChannelDefinition<unknown>, socket: QuickdrawSocket, payload: unknown): boolean;
|
|
277
355
|
/**
|
|
278
356
|
* Verify that all expected service methods have been defined via defineMethod().
|
|
279
357
|
* Call at the end of initMethods() to catch missing implementations at boot time.
|
|
@@ -416,6 +494,7 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
|
|
|
416
494
|
private readonly logResponses;
|
|
417
495
|
private connectionHandlerInstalled;
|
|
418
496
|
private readonly methodRegistry;
|
|
497
|
+
private readonly channelRegistry;
|
|
419
498
|
constructor(io: Server, options?: ServiceRegistryOptions);
|
|
420
499
|
/**
|
|
421
500
|
* Install the single connection handler that wires all registered services.
|
|
@@ -434,6 +513,20 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
|
|
|
434
513
|
* Discover and store all public methods from a service.
|
|
435
514
|
*/
|
|
436
515
|
private discoverServiceMethods;
|
|
516
|
+
/**
|
|
517
|
+
* Discover and store all channels from a service.
|
|
518
|
+
*/
|
|
519
|
+
private discoverServiceChannels;
|
|
520
|
+
/**
|
|
521
|
+
* Register a channel as a socket event listener.
|
|
522
|
+
*
|
|
523
|
+
* This is the hot path for high-frequency traffic: no ack, no async access
|
|
524
|
+
* checks, no per-message logging. Order of operations per message:
|
|
525
|
+
* token bucket → schema validation → in-memory access check → handler.
|
|
526
|
+
* Any failure silently drops the message; sustained extreme flooding
|
|
527
|
+
* disconnects the socket.
|
|
528
|
+
*/
|
|
529
|
+
private registerChannelListener;
|
|
437
530
|
/**
|
|
438
531
|
* Register a method as a socket event listener.
|
|
439
532
|
*/
|
|
@@ -624,6 +717,12 @@ interface RateLimitOptions {
|
|
|
624
717
|
* Events to exclude from rate limiting (e.g., ['ping', 'pong'])
|
|
625
718
|
*/
|
|
626
719
|
excludeEvents?: string[];
|
|
720
|
+
/**
|
|
721
|
+
* Event-name prefixes to exclude from rate limiting.
|
|
722
|
+
* Pass `[CHANNEL_EVENT_PREFIX]` ("channel:") so channel traffic bypasses
|
|
723
|
+
* the global limiter — channels enforce their own per-socket token buckets.
|
|
724
|
+
*/
|
|
725
|
+
excludePrefixes?: string[];
|
|
627
726
|
/**
|
|
628
727
|
* Custom key generator for grouping requests.
|
|
629
728
|
* Default uses socket.id.
|
|
@@ -662,7 +761,7 @@ interface RateLimiter {
|
|
|
662
761
|
/**
|
|
663
762
|
* Configuration options.
|
|
664
763
|
*/
|
|
665
|
-
options: Required<Pick<RateLimitOptions, "windowMs" | "maxRequests" | "excludeEvents">>;
|
|
764
|
+
options: Required<Pick<RateLimitOptions, "windowMs" | "maxRequests" | "excludeEvents" | "excludePrefixes">>;
|
|
666
765
|
}
|
|
667
766
|
/**
|
|
668
767
|
* Create a rate limiter instance.
|
package/dist/server/index.js
CHANGED
|
@@ -8,6 +8,9 @@ import * as fs from 'fs';
|
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
|
|
10
10
|
// src/shared/types.ts
|
|
11
|
+
function channelEventName(serviceName, channelName) {
|
|
12
|
+
return `channel:${serviceName}:${channelName}`;
|
|
13
|
+
}
|
|
11
14
|
var consoleLogger = {
|
|
12
15
|
info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? ""),
|
|
13
16
|
warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? ""),
|
|
@@ -176,6 +179,8 @@ var BaseService = class {
|
|
|
176
179
|
io = null;
|
|
177
180
|
// Collection of public methods for registry discovery
|
|
178
181
|
publicMethods = /* @__PURE__ */ new Map();
|
|
182
|
+
// Collection of channels for registry discovery
|
|
183
|
+
publicChannels = /* @__PURE__ */ new Map();
|
|
179
184
|
// Admin metadata configuration (set by installAdminMethods)
|
|
180
185
|
adminMeta = null;
|
|
181
186
|
constructor(options) {
|
|
@@ -384,6 +389,36 @@ var BaseService = class {
|
|
|
384
389
|
anySocket.to(roomName).emit(eventName, data);
|
|
385
390
|
this.logger.debug(`Emitted ${eventName} to room ${roomName} (via socket fallback)`);
|
|
386
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Emit a custom event to a room using Socket.io's volatile flag.
|
|
394
|
+
*
|
|
395
|
+
* Volatile events are dropped for clients whose connection is backpressured
|
|
396
|
+
* instead of being buffered — exactly what you want for high-frequency state
|
|
397
|
+
* that is superseded by the next emit (game snapshots, cursor positions).
|
|
398
|
+
* Never use this for events a client must not miss.
|
|
399
|
+
*
|
|
400
|
+
* Intentionally does no per-call logging: this is a hot path, typically
|
|
401
|
+
* called at tick rate (10–60Hz).
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* ```typescript
|
|
405
|
+
* // In a 20Hz game loop
|
|
406
|
+
* this.emitToRoomVolatile(
|
|
407
|
+
* this.getRoomName(worldId),
|
|
408
|
+
* 'game:snapshot',
|
|
409
|
+
* snapshot
|
|
410
|
+
* );
|
|
411
|
+
* ```
|
|
412
|
+
*/
|
|
413
|
+
emitToRoomVolatile(roomName, eventName, data) {
|
|
414
|
+
if (this.io) {
|
|
415
|
+
this.io.to(roomName).volatile.emit(eventName, data);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const anySocket = this.getAnySubscriberSocket();
|
|
419
|
+
if (!anySocket) return;
|
|
420
|
+
anySocket.to(roomName).volatile.emit(eventName, data);
|
|
421
|
+
}
|
|
387
422
|
/**
|
|
388
423
|
* Emit a custom event to a specific user's room.
|
|
389
424
|
* Users join `user:{userId}` on connection (set up in auth middleware).
|
|
@@ -668,6 +703,78 @@ var BaseService = class {
|
|
|
668
703
|
getPublicMethods() {
|
|
669
704
|
return Array.from(this.publicMethods.values());
|
|
670
705
|
}
|
|
706
|
+
// ===========================================================================
|
|
707
|
+
// Channel Definition (high-frequency, fire-and-forget events)
|
|
708
|
+
// ===========================================================================
|
|
709
|
+
/**
|
|
710
|
+
* Define a channel: a fire-and-forget event for high-frequency traffic
|
|
711
|
+
* (game input, cursor positions, typing indicators).
|
|
712
|
+
*
|
|
713
|
+
* Channels differ from methods:
|
|
714
|
+
* - No ack, no response — the handler returns void and errors are logged,
|
|
715
|
+
* never sent to the client.
|
|
716
|
+
* - Exempt from the global rate limiter; each channel enforces its own
|
|
717
|
+
* per-socket token bucket (`ratePerSecond`/`burst`). Excess messages are
|
|
718
|
+
* silently dropped; sustained extreme abuse disconnects the socket.
|
|
719
|
+
* - Access checks are fully synchronous and in-memory: authentication is
|
|
720
|
+
* always required; "Moderate"/"Admin" check the socket's serviceAccess;
|
|
721
|
+
* entry-level access is expressed via `requireRoom` (the socket must
|
|
722
|
+
* already be in that room, which was ACL-gated at subscribe time).
|
|
723
|
+
* - `schema` is required — channel payloads are untrusted input arriving
|
|
724
|
+
* at tick rate.
|
|
725
|
+
*
|
|
726
|
+
* Routes as the Socket.io event `channel:<serviceName>:<name>`.
|
|
727
|
+
*
|
|
728
|
+
* @example
|
|
729
|
+
* ```typescript
|
|
730
|
+
* this.defineChannel(
|
|
731
|
+
* "input",
|
|
732
|
+
* "Read",
|
|
733
|
+
* (payload, ctx) => this.sim.applyInput(ctx.userId, payload),
|
|
734
|
+
* {
|
|
735
|
+
* schema: gameInputSchema,
|
|
736
|
+
* ratePerSecond: 30,
|
|
737
|
+
* requireRoom: () => this.getRoomName(GLOBAL_WORLD_ID),
|
|
738
|
+
* },
|
|
739
|
+
* );
|
|
740
|
+
* ```
|
|
741
|
+
*/
|
|
742
|
+
defineChannel(name, access, handler, options) {
|
|
743
|
+
const ratePerSecond = options.ratePerSecond ?? 30;
|
|
744
|
+
const definition = {
|
|
745
|
+
name,
|
|
746
|
+
access,
|
|
747
|
+
handler,
|
|
748
|
+
schema: options.schema,
|
|
749
|
+
ratePerSecond,
|
|
750
|
+
burst: options.burst ?? ratePerSecond * 2,
|
|
751
|
+
requireRoom: options.requireRoom
|
|
752
|
+
};
|
|
753
|
+
this.publicChannels.set(name, definition);
|
|
754
|
+
return definition;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Get all channels for registry discovery.
|
|
758
|
+
*/
|
|
759
|
+
getPublicChannels() {
|
|
760
|
+
return Array.from(this.publicChannels.values());
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Synchronous, in-memory access check for a channel message.
|
|
764
|
+
* Called by the registry on the hot path — must not touch the database.
|
|
765
|
+
* Returns false to silently drop the message.
|
|
766
|
+
*/
|
|
767
|
+
checkChannelAccess(channel, socket, payload) {
|
|
768
|
+
if (!socket.userId) return false;
|
|
769
|
+
if (channel.access !== "Public" && channel.access !== "Read" && !this.hasServiceAccess(socket, channel.access)) {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
if (channel.requireRoom) {
|
|
773
|
+
const roomName = channel.requireRoom(payload);
|
|
774
|
+
if (roomName && !socket.rooms.has(roomName)) return false;
|
|
775
|
+
}
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
671
778
|
/**
|
|
672
779
|
* Verify that all expected service methods have been defined via defineMethod().
|
|
673
780
|
* Call at the end of initMethods() to catch missing implementations at boot time.
|
|
@@ -994,6 +1101,8 @@ var BaseService = class {
|
|
|
994
1101
|
};
|
|
995
1102
|
|
|
996
1103
|
// src/server/ServiceRegistry.ts
|
|
1104
|
+
var CHANNEL_ABUSE_WINDOW_MS = 1e4;
|
|
1105
|
+
var CHANNEL_ABUSE_MULTIPLIER = 100;
|
|
997
1106
|
var ServiceRegistry = class {
|
|
998
1107
|
io;
|
|
999
1108
|
services = /* @__PURE__ */ new Map();
|
|
@@ -1004,6 +1113,8 @@ var ServiceRegistry = class {
|
|
|
1004
1113
|
connectionHandlerInstalled = false;
|
|
1005
1114
|
// Store method metadata for deferred registration
|
|
1006
1115
|
methodRegistry = /* @__PURE__ */ new Map();
|
|
1116
|
+
// Store channel metadata for deferred registration
|
|
1117
|
+
channelRegistry = /* @__PURE__ */ new Map();
|
|
1007
1118
|
constructor(io, options) {
|
|
1008
1119
|
this.io = io;
|
|
1009
1120
|
this.logger = options?.logger?.child({ service: "ServiceRegistry" }) ?? consoleLogger.child({ service: "ServiceRegistry" });
|
|
@@ -1037,6 +1148,9 @@ var ServiceRegistry = class {
|
|
|
1037
1148
|
for (const [eventName, { method, service }] of this.methodRegistry) {
|
|
1038
1149
|
this.registerMethodListener(socket, eventName, method, service);
|
|
1039
1150
|
}
|
|
1151
|
+
for (const [eventName, { channel, service }] of this.channelRegistry) {
|
|
1152
|
+
this.registerChannelListener(socket, eventName, channel, service);
|
|
1153
|
+
}
|
|
1040
1154
|
for (const [serviceName, service] of this.services) {
|
|
1041
1155
|
this.registerSubscriptionListener(socket, `${serviceName}:subscribe`, service);
|
|
1042
1156
|
this.registerBatchSubscriptionListener(socket, `${serviceName}:batchSubscribe`, service);
|
|
@@ -1053,6 +1167,7 @@ var ServiceRegistry = class {
|
|
|
1053
1167
|
}
|
|
1054
1168
|
this.logger.info(`Registered service: ${serviceName}`);
|
|
1055
1169
|
this.discoverServiceMethods(serviceName, service);
|
|
1170
|
+
this.discoverServiceChannels(serviceName, service);
|
|
1056
1171
|
this.installConnectionHandler();
|
|
1057
1172
|
}
|
|
1058
1173
|
/**
|
|
@@ -1070,6 +1185,77 @@ var ServiceRegistry = class {
|
|
|
1070
1185
|
});
|
|
1071
1186
|
}
|
|
1072
1187
|
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Discover and store all channels from a service.
|
|
1190
|
+
*/
|
|
1191
|
+
discoverServiceChannels(serviceName, service) {
|
|
1192
|
+
const channels = service.getPublicChannels?.() ?? [];
|
|
1193
|
+
for (const channel of channels) {
|
|
1194
|
+
const eventName = channelEventName(serviceName, channel.name);
|
|
1195
|
+
this.logger.info(
|
|
1196
|
+
`Registering channel event: ${eventName} (${channel.ratePerSecond}/s, burst ${channel.burst})`
|
|
1197
|
+
);
|
|
1198
|
+
this.channelRegistry.set(eventName, {
|
|
1199
|
+
serviceName,
|
|
1200
|
+
channel,
|
|
1201
|
+
service
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Register a channel as a socket event listener.
|
|
1207
|
+
*
|
|
1208
|
+
* This is the hot path for high-frequency traffic: no ack, no async access
|
|
1209
|
+
* checks, no per-message logging. Order of operations per message:
|
|
1210
|
+
* token bucket → schema validation → in-memory access check → handler.
|
|
1211
|
+
* Any failure silently drops the message; sustained extreme flooding
|
|
1212
|
+
* disconnects the socket.
|
|
1213
|
+
*/
|
|
1214
|
+
registerChannelListener(socket, eventName, channel, service) {
|
|
1215
|
+
let tokens = channel.burst;
|
|
1216
|
+
let lastRefillAt = Date.now();
|
|
1217
|
+
let abuseWindowStart = lastRefillAt;
|
|
1218
|
+
let droppedInWindow = 0;
|
|
1219
|
+
socket.on(eventName, (payload) => {
|
|
1220
|
+
const now = Date.now();
|
|
1221
|
+
tokens = Math.min(
|
|
1222
|
+
channel.burst,
|
|
1223
|
+
tokens + (now - lastRefillAt) / 1e3 * channel.ratePerSecond
|
|
1224
|
+
);
|
|
1225
|
+
lastRefillAt = now;
|
|
1226
|
+
if (tokens < 1) {
|
|
1227
|
+
if (now - abuseWindowStart > CHANNEL_ABUSE_WINDOW_MS) {
|
|
1228
|
+
abuseWindowStart = now;
|
|
1229
|
+
droppedInWindow = 0;
|
|
1230
|
+
}
|
|
1231
|
+
droppedInWindow++;
|
|
1232
|
+
if (droppedInWindow > channel.ratePerSecond * CHANNEL_ABUSE_MULTIPLIER) {
|
|
1233
|
+
this.logger.warn(
|
|
1234
|
+
`Disconnecting socket ${socket.id} for sustained channel flooding on ${eventName}`,
|
|
1235
|
+
{ userId: socket.userId, droppedInWindow }
|
|
1236
|
+
);
|
|
1237
|
+
socket.disconnect(true);
|
|
1238
|
+
}
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
tokens -= 1;
|
|
1242
|
+
const parsed = channel.schema.safeParse(payload);
|
|
1243
|
+
if (!parsed.success) return;
|
|
1244
|
+
if (!service.checkChannelAccess?.(channel, socket, parsed.data)) return;
|
|
1245
|
+
try {
|
|
1246
|
+
channel.handler(parsed.data, {
|
|
1247
|
+
userId: socket.userId,
|
|
1248
|
+
socketId: socket.id,
|
|
1249
|
+
serviceAccess: socket.serviceAccess ?? {}
|
|
1250
|
+
});
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
this.logger.error(`Channel handler error for ${eventName}`, {
|
|
1253
|
+
userId: socket.userId,
|
|
1254
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1073
1259
|
/**
|
|
1074
1260
|
* Register a method as a socket event listener.
|
|
1075
1261
|
*/
|
|
@@ -1464,7 +1650,7 @@ async function isRedisAdapterAvailable() {
|
|
|
1464
1650
|
|
|
1465
1651
|
// src/server/rateLimit.ts
|
|
1466
1652
|
function createRateLimiter(options = {}) {
|
|
1467
|
-
const { windowMs = 6e4, maxRequests = 100, excludeEvents = [] } = options;
|
|
1653
|
+
const { windowMs = 6e4, maxRequests = 100, excludeEvents = [], excludePrefixes = [] } = options;
|
|
1468
1654
|
const entries = /* @__PURE__ */ new Map();
|
|
1469
1655
|
const cleanupInterval = setInterval(() => {
|
|
1470
1656
|
const now = Date.now();
|
|
@@ -1516,7 +1702,8 @@ function createRateLimiter(options = {}) {
|
|
|
1516
1702
|
options: {
|
|
1517
1703
|
windowMs,
|
|
1518
1704
|
maxRequests,
|
|
1519
|
-
excludeEvents
|
|
1705
|
+
excludeEvents,
|
|
1706
|
+
excludePrefixes
|
|
1520
1707
|
}
|
|
1521
1708
|
};
|
|
1522
1709
|
}
|
|
@@ -1530,13 +1717,17 @@ function applyRateLimitMiddleware(io, rateLimiter, options = {}) {
|
|
|
1530
1717
|
retryAfter: rateLimiter.getResetTime(keyGenerator(socket, eventName))
|
|
1531
1718
|
});
|
|
1532
1719
|
});
|
|
1533
|
-
const { excludeEvents } = rateLimiter.options;
|
|
1720
|
+
const { excludeEvents, excludePrefixes } = rateLimiter.options;
|
|
1534
1721
|
io.on("connection", (socket) => {
|
|
1535
1722
|
socket.use(([eventName, ...args], next) => {
|
|
1536
1723
|
if (excludeEvents.includes(eventName)) {
|
|
1537
1724
|
next();
|
|
1538
1725
|
return;
|
|
1539
1726
|
}
|
|
1727
|
+
if (excludePrefixes.some((prefix) => eventName.startsWith(prefix))) {
|
|
1728
|
+
next();
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1540
1731
|
if (eventName.startsWith("$")) {
|
|
1541
1732
|
next();
|
|
1542
1733
|
return;
|