@fitzzero/quickdraw-core 3.7.0 → 3.9.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.
@@ -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-DniMxkQs.js';
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.
@@ -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;