@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.
@@ -1,7 +1,7 @@
1
- import { a as QuickdrawSocket, P as PrismaDelegate, b as BaseServiceOptions, I as InstallAdminMethodsOptions, S as ServiceRegistryInstance, B as BaseServiceInstance, c as QuickdrawServerOptions, Q as QuickdrawServerResult } from '../types-OhRxDBID.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-CDhCEpTU.js';
2
+ import { Server, Socket } from 'socket.io';
2
3
  import { z } from 'zod';
3
4
  import { ACL, Logger, AccessLevel, ServiceMethodContext, ServiceMethodDefinition } from '../shared/index.js';
4
- import { Server } from 'socket.io';
5
5
  import 'http';
6
6
 
7
7
  /**
@@ -52,6 +52,7 @@ declare abstract class BaseService<TEntity extends {
52
52
  protected readonly logger: Logger;
53
53
  protected readonly subscribers: Map<string, Set<QuickdrawSocket>>;
54
54
  protected delegate: PrismaDelegate<TEntity, TCreateInput, TUpdateInput> | undefined;
55
+ protected io: Server | null;
55
56
  private readonly publicMethods;
56
57
  constructor(options: BaseServiceOptions);
57
58
  /**
@@ -59,13 +60,24 @@ declare abstract class BaseService<TEntity extends {
59
60
  * Must be called in the constructor of derived classes.
60
61
  */
61
62
  protected setDelegate(delegate: PrismaDelegate<TEntity, TCreateInput, TUpdateInput>): void;
63
+ /**
64
+ * Set the Socket.io server instance for room-based broadcasts.
65
+ * Called automatically by ServiceRegistry during registration.
66
+ */
67
+ setIo(io: Server): void;
62
68
  /**
63
69
  * Get the delegate, throwing if not set.
64
70
  */
65
71
  protected getDelegate(): PrismaDelegate<TEntity, TCreateInput, TUpdateInput>;
72
+ /**
73
+ * Get the Socket.io room name for an entry.
74
+ * Used for room-based broadcasting across services.
75
+ */
76
+ getRoomName(entryId: string): string;
66
77
  /**
67
78
  * Subscribe a socket to an entity's updates.
68
79
  * Returns the current entity data if access is granted, null otherwise.
80
+ * Also joins the socket to the entity's Socket.io room for cross-service events.
69
81
  */
70
82
  subscribe(entryId: string, socket: QuickdrawSocket, requiredLevel?: AccessLevel): Promise<TEntity | null>;
71
83
  /**
@@ -76,8 +88,33 @@ declare abstract class BaseService<TEntity extends {
76
88
  * Remove a socket from all subscriptions (called on disconnect).
77
89
  */
78
90
  unsubscribeSocket(socket: QuickdrawSocket): void;
91
+ /**
92
+ * Emit a custom event to all subscribers of an entry via Socket.io rooms.
93
+ * This is useful for cross-service events (e.g., message service notifying chat subscribers).
94
+ *
95
+ * @param roomName - The Socket.io room name (use getRoomName for service rooms)
96
+ * @param eventName - The event name to emit
97
+ * @param data - The data to emit
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * // In MessageService, notify chat subscribers of a new message
102
+ * this.emitToRoom(
103
+ * `chatService:${message.chatId}`,
104
+ * 'chat:message',
105
+ * messageDTO
106
+ * );
107
+ * ```
108
+ */
109
+ protected emitToRoom(roomName: string, eventName: string, data: unknown): void;
110
+ /**
111
+ * Get any active socket for room-based operations.
112
+ */
113
+ private getAnySubscriberSocket;
79
114
  /**
80
115
  * Emit an update to all subscribers of an entity.
116
+ * Uses tier-based pre-filtering: computes filtered versions once,
117
+ * then selects the appropriate version per subscriber.
81
118
  */
82
119
  protected emitUpdate(entryId: string, data: Partial<TEntity>): void;
83
120
  /**
@@ -107,6 +144,56 @@ declare abstract class BaseService<TEntity extends {
107
144
  * Throws if access is denied.
108
145
  */
109
146
  ensureAccessForMethod(requiredLevel: AccessLevel, socket: QuickdrawSocket, entryId?: string): Promise<void>;
147
+ /**
148
+ * Get the list of fields that should be stripped for non-elevated subscribers.
149
+ * Override in derived classes to customize which fields are protected.
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * protected override getProtectedFields(): (keyof User)[] {
154
+ * return ['email', 'serviceAccess', 'discordId'];
155
+ * }
156
+ * ```
157
+ */
158
+ protected getProtectedFields(): (keyof TEntity)[];
159
+ /**
160
+ * Check if a socket has elevated access to an entity (receives full data).
161
+ * Default: owner (socket.userId === entryId) or service-level Admin.
162
+ * Override in derived classes for custom logic.
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * protected override hasElevatedAccess(socket: QuickdrawSocket, entryId: string): boolean {
167
+ * // Friends can see more data
168
+ * return super.hasElevatedAccess(socket, entryId) ||
169
+ * this.isFriend(socket.userId, entryId);
170
+ * }
171
+ * ```
172
+ */
173
+ protected hasElevatedAccess(socket: QuickdrawSocket, entryId: string): boolean;
174
+ /**
175
+ * Strip protected fields from an entity.
176
+ * Used internally by filterEntityForSubscriber.
177
+ */
178
+ protected stripProtectedFields<T extends Partial<TEntity>>(entity: T): T;
179
+ /**
180
+ * Filter entity data based on subscriber's access level.
181
+ * Override for complex filtering logic (e.g., multiple tiers).
182
+ *
183
+ * @example
184
+ * ```typescript
185
+ * protected override filterEntityForSubscriber(
186
+ * entity: Partial<TEntity>,
187
+ * socket: QuickdrawSocket,
188
+ * entryId: string
189
+ * ): Partial<TEntity> {
190
+ * if (this.hasElevatedAccess(socket, entryId)) return entity;
191
+ * if (this.isFriend(socket.userId, entryId)) return this.stripForFriends(entity);
192
+ * return this.stripProtectedFields(entity);
193
+ * }
194
+ * ```
195
+ */
196
+ protected filterEntityForSubscriber(entity: Partial<TEntity>, socket: QuickdrawSocket, entryId: string): Partial<TEntity>;
110
197
  /**
111
198
  * Find an entity by ID.
112
199
  */
@@ -139,8 +226,72 @@ declare abstract class BaseService<TEntity extends {
139
226
  /**
140
227
  * Install standard admin CRUD methods.
141
228
  * Call this in derived class constructor to expose admin endpoints.
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * this.installAdminMethods({
233
+ * expose: { list: true, get: true, create: true, update: true, delete: true },
234
+ * access: {
235
+ * list: "Admin",
236
+ * get: "Admin",
237
+ * create: "Admin",
238
+ * update: "Admin",
239
+ * delete: "Admin",
240
+ * setEntryACL: "Admin",
241
+ * getSubscribers: "Admin",
242
+ * reemit: "Admin",
243
+ * unsubscribeAll: "Admin",
244
+ * },
245
+ * });
246
+ * ```
247
+ */
248
+ protected installAdminMethods(options: InstallAdminMethodsOptions): void;
249
+ /**
250
+ * Admin method: List entities with pagination and filters.
251
+ */
252
+ protected adminList(payload: AdminListPayload): Promise<AdminListResponse<TEntity>>;
253
+ /**
254
+ * Admin method: Get a single entity by ID.
255
+ */
256
+ protected adminGet(id: string): Promise<TEntity | null>;
257
+ /**
258
+ * Admin method: Create an entity.
259
+ */
260
+ protected adminCreate(data: TCreateInput): Promise<TEntity>;
261
+ /**
262
+ * Admin method: Update an entity.
263
+ */
264
+ protected adminUpdate(id: string, data: TUpdateInput): Promise<TEntity | null>;
265
+ /**
266
+ * Admin method: Delete an entity.
267
+ */
268
+ protected adminDelete(id: string): Promise<{
269
+ success: boolean;
270
+ id: string;
271
+ }>;
272
+ /**
273
+ * Admin method: Set entry-level ACL.
274
+ */
275
+ protected adminSetEntryACL(payload: AdminSetACLPayload): Promise<TEntity | null>;
276
+ /**
277
+ * Admin method: Get active subscribers for an entity.
278
+ */
279
+ protected adminGetSubscribers(entryId: string): AdminSubscribersResponse;
280
+ /**
281
+ * Admin method: Re-emit current entity state to all subscribers.
282
+ * Useful when you've made direct DB changes or need to force-refresh clients.
283
+ */
284
+ protected adminReemit(entryId: string): Promise<{
285
+ success: boolean;
286
+ subscriberCount: number;
287
+ }>;
288
+ /**
289
+ * Admin method: Unsubscribe all sockets from an entity.
142
290
  */
143
- protected installAdminMethods(_options: InstallAdminMethodsOptions): void;
291
+ protected adminUnsubscribeAll(entryId: string): {
292
+ success: boolean;
293
+ unsubscribedCount: number;
294
+ };
144
295
  /**
145
296
  * Fields that should never be exposed or editable via admin methods.
146
297
  */
@@ -155,6 +306,7 @@ declare abstract class BaseService<TEntity extends {
155
306
  * - Wires methods to Socket.io events with standardized naming
156
307
  * - Handles subscription lifecycle
157
308
  * - Provides consistent error handling and logging
309
+ * - Uses a single connection handler for all services (memory efficient)
158
310
  *
159
311
  * @example
160
312
  * ```typescript
@@ -167,15 +319,26 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
167
319
  private readonly io;
168
320
  private readonly services;
169
321
  private readonly logger;
322
+ private connectionHandlerInstalled;
323
+ private readonly methodRegistry;
170
324
  constructor(io: Server, options?: {
171
325
  logger?: Logger;
172
326
  });
327
+ /**
328
+ * Install the single connection handler that wires all registered services.
329
+ * This is called lazily on first service registration.
330
+ */
331
+ private installConnectionHandler;
332
+ /**
333
+ * Set up all event listeners for a newly connected socket.
334
+ */
335
+ private setupSocketListeners;
173
336
  /**
174
337
  * Register a service and auto-discover its public methods.
175
338
  */
176
339
  registerService(serviceName: string, service: BaseServiceInstance): void;
177
340
  /**
178
- * Discover and register all public methods from a service.
341
+ * Discover and store all public methods from a service.
179
342
  */
180
343
  private discoverServiceMethods;
181
344
  /**
@@ -227,6 +390,255 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
227
390
  */
228
391
  declare function createQuickdrawServer(options: QuickdrawServerOptions): QuickdrawServerResult;
229
392
 
393
+ /**
394
+ * Redis adapter utilities for horizontal scaling.
395
+ *
396
+ * This module provides optional Redis integration for Socket.io
397
+ * to enable running multiple server instances.
398
+ *
399
+ * @example
400
+ * ```typescript
401
+ * import { createQuickdrawServer } from '@fitzzero/quickdraw-core/server';
402
+ * import { setupRedisAdapter } from '@fitzzero/quickdraw-core/server';
403
+ *
404
+ * const { io } = createQuickdrawServer({ ... });
405
+ *
406
+ * // Enable Redis for horizontal scaling
407
+ * await setupRedisAdapter(io, {
408
+ * host: process.env.REDIS_HOST ?? 'localhost',
409
+ * port: parseInt(process.env.REDIS_PORT ?? '6379'),
410
+ * });
411
+ * ```
412
+ */
413
+
414
+ /**
415
+ * Redis adapter configuration options.
416
+ */
417
+ interface RedisAdapterOptions {
418
+ /**
419
+ * Redis host (default: 'localhost')
420
+ */
421
+ host?: string;
422
+ /**
423
+ * Redis port (default: 6379)
424
+ */
425
+ port?: number;
426
+ /**
427
+ * Redis password (optional)
428
+ */
429
+ password?: string;
430
+ /**
431
+ * Redis database number (default: 0)
432
+ */
433
+ db?: number;
434
+ /**
435
+ * Key prefix for Socket.io adapter (default: 'socket.io')
436
+ */
437
+ keyPrefix?: string;
438
+ /**
439
+ * Logger instance
440
+ */
441
+ logger?: Logger;
442
+ }
443
+ /**
444
+ * Result of Redis adapter setup.
445
+ */
446
+ interface RedisAdapterResult {
447
+ /**
448
+ * Whether the adapter was successfully set up.
449
+ */
450
+ success: boolean;
451
+ /**
452
+ * Cleanup function to disconnect from Redis.
453
+ */
454
+ cleanup: () => Promise<void>;
455
+ }
456
+ /**
457
+ * Set up Redis adapter for Socket.io horizontal scaling.
458
+ *
459
+ * This function dynamically imports the @socket.io/redis-adapter package
460
+ * to avoid requiring it as a hard dependency.
461
+ *
462
+ * @param io - Socket.io server instance
463
+ * @param options - Redis configuration options
464
+ * @returns Promise resolving to setup result
465
+ *
466
+ * @example
467
+ * ```typescript
468
+ * const { io } = createQuickdrawServer({ ... });
469
+ *
470
+ * const { success, cleanup } = await setupRedisAdapter(io, {
471
+ * host: 'redis.example.com',
472
+ * port: 6379,
473
+ * password: process.env.REDIS_PASSWORD,
474
+ * });
475
+ *
476
+ * if (success) {
477
+ * console.log('Redis adapter enabled - horizontal scaling ready');
478
+ * }
479
+ *
480
+ * // On shutdown:
481
+ * await cleanup();
482
+ * ```
483
+ */
484
+ declare function setupRedisAdapter(io: Server, options?: RedisAdapterOptions): Promise<RedisAdapterResult>;
485
+ /**
486
+ * Check if Redis adapter packages are available.
487
+ *
488
+ * @returns Promise resolving to true if packages are installed
489
+ */
490
+ declare function isRedisAdapterAvailable(): Promise<boolean>;
491
+
492
+ /**
493
+ * Rate limiting middleware for Socket.io events.
494
+ *
495
+ * Provides per-socket rate limiting to prevent abuse and ensure
496
+ * fair resource usage across clients.
497
+ *
498
+ * @example
499
+ * ```typescript
500
+ * import { createRateLimiter, applyRateLimitMiddleware } from '@fitzzero/quickdraw-core/server';
501
+ *
502
+ * // Create a rate limiter
503
+ * const rateLimiter = createRateLimiter({
504
+ * windowMs: 60000, // 1 minute window
505
+ * maxRequests: 100, // 100 requests per window
506
+ * });
507
+ *
508
+ * // Apply to Socket.io server
509
+ * applyRateLimitMiddleware(io, rateLimiter);
510
+ * ```
511
+ */
512
+
513
+ /**
514
+ * Rate limiter configuration options.
515
+ */
516
+ interface RateLimitOptions {
517
+ /**
518
+ * Time window in milliseconds (default: 60000 = 1 minute)
519
+ */
520
+ windowMs?: number;
521
+ /**
522
+ * Maximum number of requests per window (default: 100)
523
+ */
524
+ maxRequests?: number;
525
+ /**
526
+ * Events to exclude from rate limiting (e.g., ['ping', 'pong'])
527
+ */
528
+ excludeEvents?: string[];
529
+ /**
530
+ * Custom key generator for grouping requests.
531
+ * Default uses socket.id.
532
+ */
533
+ keyGenerator?: (socket: Socket, eventName: string) => string;
534
+ /**
535
+ * Callback when rate limit is exceeded.
536
+ */
537
+ onRateLimitExceeded?: (socket: Socket, eventName: string) => void;
538
+ /**
539
+ * Logger instance
540
+ */
541
+ logger?: Logger;
542
+ }
543
+ /**
544
+ * Rate limiter instance.
545
+ */
546
+ interface RateLimiter {
547
+ /**
548
+ * Check if a request should be allowed.
549
+ * Returns true if allowed, false if rate limited.
550
+ */
551
+ check: (key: string) => boolean;
552
+ /**
553
+ * Get remaining requests for a key.
554
+ */
555
+ getRemaining: (key: string) => number;
556
+ /**
557
+ * Get time until rate limit resets for a key.
558
+ */
559
+ getResetTime: (key: string) => number;
560
+ /**
561
+ * Clear all rate limit entries (useful for testing).
562
+ */
563
+ clear: () => void;
564
+ /**
565
+ * Configuration options.
566
+ */
567
+ options: Required<Pick<RateLimitOptions, "windowMs" | "maxRequests" | "excludeEvents">>;
568
+ }
569
+ /**
570
+ * Create a rate limiter instance.
571
+ *
572
+ * @param options - Rate limiter configuration
573
+ * @returns Rate limiter instance
574
+ *
575
+ * @example
576
+ * ```typescript
577
+ * const limiter = createRateLimiter({
578
+ * windowMs: 60000,
579
+ * maxRequests: 100,
580
+ * });
581
+ *
582
+ * if (limiter.check('user-123')) {
583
+ * // Request allowed
584
+ * } else {
585
+ * // Rate limited
586
+ * }
587
+ * ```
588
+ */
589
+ declare function createRateLimiter(options?: RateLimitOptions): RateLimiter;
590
+ /**
591
+ * Apply rate limiting middleware to a Socket.io server.
592
+ *
593
+ * This intercepts all incoming events and checks them against
594
+ * the rate limiter before allowing them to proceed.
595
+ *
596
+ * @param io - Socket.io server instance
597
+ * @param rateLimiter - Rate limiter instance
598
+ * @param options - Additional options
599
+ *
600
+ * @example
601
+ * ```typescript
602
+ * const rateLimiter = createRateLimiter({ maxRequests: 100 });
603
+ *
604
+ * applyRateLimitMiddleware(io, rateLimiter, {
605
+ * keyGenerator: (socket) => socket.userId ?? socket.id,
606
+ * onRateLimitExceeded: (socket, event) => {
607
+ * socket.emit('error', { code: 'RATE_LIMITED', event });
608
+ * },
609
+ * });
610
+ * ```
611
+ */
612
+ declare function applyRateLimitMiddleware(io: Server, rateLimiter: RateLimiter, options?: Pick<RateLimitOptions, "keyGenerator" | "onRateLimitExceeded" | "logger">): void;
613
+ /**
614
+ * Create a tiered rate limiter with different limits for different event types.
615
+ *
616
+ * @param tiers - Map of event patterns to rate limit options
617
+ * @param defaultOptions - Default options for events not matching any tier
618
+ * @returns Rate limiter that applies different limits based on event name
619
+ *
620
+ * @example
621
+ * ```typescript
622
+ * const tieredLimiter = createTieredRateLimiter(
623
+ * {
624
+ * // Strict limits for write operations
625
+ * 'create*': { maxRequests: 10, windowMs: 60000 },
626
+ * 'update*': { maxRequests: 20, windowMs: 60000 },
627
+ * 'delete*': { maxRequests: 5, windowMs: 60000 },
628
+ * // Generous limits for reads
629
+ * 'list*': { maxRequests: 200, windowMs: 60000 },
630
+ * 'get*': { maxRequests: 200, windowMs: 60000 },
631
+ * },
632
+ * { maxRequests: 100, windowMs: 60000 } // Default
633
+ * );
634
+ * ```
635
+ */
636
+ declare function createTieredRateLimiter(tiers: Record<string, RateLimitOptions>, defaultOptions?: RateLimitOptions): {
637
+ check: (key: string, eventName: string) => boolean;
638
+ getRemaining: (key: string, eventName: string) => number;
639
+ clear: () => void;
640
+ };
641
+
230
642
  interface JWTPayload {
231
643
  userId: string;
232
644
  email?: string;
@@ -333,4 +745,4 @@ interface GoogleUser {
333
745
  */
334
746
  declare const googleProvider: OAuthProvider<GoogleUser>;
335
747
 
336
- export { BaseService, BaseServiceInstance, BaseServiceOptions, type DiscordUser, type GoogleUser, InstallAdminMethodsOptions, type JWTPayload, type OAuthConfig, type OAuthProvider, type OAuthTokenResponse, PrismaDelegate, QuickdrawServerOptions, QuickdrawServerResult, QuickdrawSocket, ServiceRegistry, ServiceRegistryInstance, createJWT, createOAuthURL, createQuickdrawServer, discordProvider, exchangeOAuthCode, googleProvider, verifyJWT };
748
+ export { AdminListPayload, AdminListResponse, AdminSetACLPayload, AdminSubscribersResponse, BaseService, BaseServiceInstance, BaseServiceOptions, type DiscordUser, type GoogleUser, InstallAdminMethodsOptions, type JWTPayload, type OAuthConfig, type OAuthProvider, type OAuthTokenResponse, PrismaDelegate, QuickdrawServerOptions, QuickdrawServerResult, QuickdrawSocket, type RateLimitOptions, type RateLimiter, type RedisAdapterOptions, type RedisAdapterResult, ServiceRegistry, ServiceRegistryInstance, applyRateLimitMiddleware, createJWT, createOAuthURL, createQuickdrawServer, createRateLimiter, createTieredRateLimiter, discordProvider, exchangeOAuthCode, googleProvider, isRedisAdapterAvailable, setupRedisAdapter, verifyJWT };