@signe/room 1.4.2 → 2.0.1

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/src/server.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  isClass,
17
17
  throttle,
18
18
  } from "./utils";
19
+ import { ServerResponse } from "./request/response";
20
+ import { createCorsInterceptor } from "./request/cors";
19
21
 
20
22
  const Message = z.object({
21
23
  action: z.string(),
@@ -61,19 +63,19 @@ export class Server implements Party.Server {
61
63
  * const server = new MyServer(new ServerIo("game"));
62
64
  * ```
63
65
  */
64
- constructor(readonly room: Party.Room) {}
66
+ constructor(readonly room: Party.Room) { }
65
67
 
66
- /**
67
- * @readonly
68
- * @property {boolean} isHibernate - Indicates whether the server is in hibernate mode.
69
- *
70
- * @example
71
- * ```typescript
72
- * if (!server.isHibernate) {
73
- * console.log("Server is active");
74
- * }
75
- * ```
76
- */
68
+ /**
69
+ * @readonly
70
+ * @property {boolean} isHibernate - Indicates whether the server is in hibernate mode.
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * if (!server.isHibernate) {
75
+ * console.log("Server is active");
76
+ * }
77
+ * ```
78
+ */
77
79
  get isHibernate(): boolean {
78
80
  return !!this["options"]?.hibernate;
79
81
  }
@@ -82,6 +84,24 @@ export class Server implements Party.Server {
82
84
  return this.room.storage
83
85
  }
84
86
 
87
+ async send(conn: Party.Connection, obj: any, subRoom: any) {
88
+ obj = structuredClone(obj);
89
+ if (subRoom.interceptorPacket) {
90
+ const signal = this.getUsersProperty(subRoom);
91
+ const { publicId } = conn.state as any;
92
+ const user = signal?.()[publicId];
93
+ obj = await awaitReturn(subRoom["interceptorPacket"]?.(user, obj, conn));
94
+ if (obj === null) return;
95
+ }
96
+ conn.send(JSON.stringify(obj));
97
+ }
98
+
99
+ broadcast(obj: any, subRoom: any) {
100
+ for (let conn of this.room.getConnections()) {
101
+ this.send(conn, obj, subRoom);
102
+ }
103
+ }
104
+
85
105
  /**
86
106
  * @method onStart
87
107
  * @async
@@ -122,7 +142,7 @@ export class Server implements Party.Server {
122
142
  // Store valid publicIds from sessions
123
143
  const validPublicIds = new Set<string>();
124
144
  const expiredPublicIds = new Set<string>();
125
- const SESSION_EXPIRY_TIME = options.sessionExpiryTime
145
+ const SESSION_EXPIRY_TIME = options.sessionExpiryTime
126
146
  const now = Date.now();
127
147
 
128
148
  for (const [key, session] of sessions) {
@@ -130,15 +150,15 @@ export class Server implements Party.Server {
130
150
  if (!key.startsWith('session:')) continue;
131
151
 
132
152
  const privateId = key.replace('session:', '');
133
- const typedSession = session as {publicId: string, created: number, connected: boolean};
134
-
153
+ const typedSession = session as { publicId: string, created: number, connected: boolean };
154
+
135
155
  // Check if session should be deleted based on:
136
156
  // 1. Connection is not active
137
157
  // 2. Session is marked as disconnected
138
158
  // 3. Session is older than expiry time
139
- if (!activePrivateIds.has(privateId) &&
140
- !typedSession.connected &&
141
- (now - typedSession.created) > SESSION_EXPIRY_TIME) {
159
+ if (!activePrivateIds.has(privateId) &&
160
+ !typedSession.connected &&
161
+ (now - typedSession.created) > SESSION_EXPIRY_TIME) {
142
162
  // Delete expired session
143
163
  await this.deleteSession(privateId);
144
164
  expiredPublicIds.add(typedSession.publicId);
@@ -159,7 +179,7 @@ export class Server implements Party.Server {
159
179
  }
160
180
  }
161
181
  }
162
-
182
+
163
183
  } catch (error) {
164
184
  console.error('Error in garbage collector:', error);
165
185
  }
@@ -229,11 +249,12 @@ export class Server implements Party.Server {
229
249
  return;
230
250
  }
231
251
  const packet = buildObject(values, instance.$memoryAll);
232
- this.room.broadcast(
233
- JSON.stringify({
252
+ this.broadcast(
253
+ {
234
254
  type: "sync",
235
255
  value: packet,
236
- })
256
+ },
257
+ instance
237
258
  );
238
259
  values.clear();
239
260
  }
@@ -247,7 +268,7 @@ export class Server implements Party.Server {
247
268
  for (let [path, value] of values) {
248
269
  const _instance =
249
270
  path == "." ? instance : getByPath(instance, path);
250
- const itemValue = createStatesSnapshot(_instance);
271
+ const itemValue = createStatesSnapshot(_instance);
251
272
  if (value == DELETE_TOKEN) {
252
273
  await this.room.storage.delete(path);
253
274
  } else {
@@ -327,17 +348,17 @@ export class Server implements Party.Server {
327
348
  return meta?.get("users")
328
349
  }
329
350
 
330
- private async getSession(privateId: string): Promise<{publicId: string, state?: any, created?: number, connected?: boolean} | null> {
351
+ private async getSession(privateId: string): Promise<{ publicId: string, state?: any, created?: number, connected?: boolean } | null> {
331
352
  if (!privateId) return null;
332
353
  try {
333
354
  const session = await this.room.storage.get(`session:${privateId}`);
334
- return session as {publicId: string, state?: any, created: number, connected: boolean} | null;
355
+ return session as { publicId: string, state?: any, created: number, connected: boolean } | null;
335
356
  } catch (e) {
336
357
  return null;
337
358
  }
338
359
  }
339
360
 
340
- private async saveSession(privateId: string, data: {publicId: string, state?: any, created?: number, connected?: boolean}) {
361
+ private async saveSession(privateId: string, data: { publicId: string, state?: any, created?: number, connected?: boolean }) {
341
362
  const sessionData = {
342
363
  ...data,
343
364
  created: data.created || Date.now(),
@@ -357,23 +378,7 @@ export class Server implements Party.Server {
357
378
  await this.room.storage.delete(`session:${privateId}`);
358
379
  }
359
380
 
360
- /**
361
- * @method onConnect
362
- * @async
363
- * @param {Party.Connection} conn - The connection object for the new user.
364
- * @param {Party.ConnectionContext} ctx - The context of the connection.
365
- * @description Handles a new user connection, creates a user object, and sends initial sync data.
366
- * @returns {Promise<void>}
367
- *
368
- * @example
369
- * ```typescript
370
- * server.onConnect = async (conn, ctx) => {
371
- * await server.onConnect(conn, ctx);
372
- * console.log("New user connected:", conn.id);
373
- * };
374
- * ```
375
- */
376
- async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
381
+ async onConnectClient(conn: Party.Connection, ctx: Party.ConnectionContext) {
377
382
  const subRoom = await this.getSubRoom({
378
383
  getMemoryAll: true,
379
384
  })
@@ -397,7 +402,7 @@ export class Server implements Party.Server {
397
402
  }
398
403
 
399
404
  // Check for existing session
400
- const existingSession = await this.getSession(conn.id)
405
+ const existingSession = await this.getSession(conn.id)
401
406
 
402
407
  // Generate IDs
403
408
  const publicId = existingSession?.publicId || generateShortUUID();
@@ -408,7 +413,7 @@ export class Server implements Party.Server {
408
413
 
409
414
  if (signal) {
410
415
  const { classType } = signal.options;
411
-
416
+
412
417
  // Restore state if exists
413
418
  if (!existingSession?.publicId) {
414
419
  user = isClass(classType) ? new classType() : classType(conn, ctx);
@@ -416,7 +421,7 @@ export class Server implements Party.Server {
416
421
  const snapshot = createStatesSnapshot(user);
417
422
  this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
418
423
  }
419
-
424
+
420
425
  // Only store new session if it doesn't exist
421
426
  if (!existingSession) {
422
427
  await this.saveSession(conn.id, {
@@ -430,40 +435,82 @@ export class Server implements Party.Server {
430
435
 
431
436
  // Call the room's onJoin method if it exists
432
437
  await awaitReturn(subRoom["onJoin"]?.(user, conn, ctx));
433
-
438
+
434
439
  // Store both IDs in connection state
435
- conn.setState({ publicId });
440
+ conn.setState({
441
+ ...conn.state,
442
+ publicId
443
+ });
436
444
 
437
445
  // Send initial sync data with both IDs to the new connection
438
- conn.send(
439
- JSON.stringify({
440
- type: "sync",
441
- value: {
442
- pId: publicId,
443
- ...subRoom.$memoryAll,
444
- },
445
- })
446
- );
446
+ this.send(conn, {
447
+ type: "sync",
448
+ value: {
449
+ pId: publicId,
450
+ ...subRoom.$memoryAll,
451
+ },
452
+ }, subRoom)
447
453
  }
448
454
 
449
455
  /**
450
- * @method onMessage
456
+ * @method onConnect
451
457
  * @async
452
- * @param {string} message - The message received from a user.
453
- * @param {Party.Connection} sender - The connection object of the sender.
454
- * @description Processes incoming messages and triggers corresponding actions in the sub-room.
458
+ * @param {Party.Connection} conn - The connection object for the new user.
459
+ * @param {Party.ConnectionContext} ctx - The context of the connection.
460
+ * @description Handles a new user connection, creates a user object, and sends initial sync data.
455
461
  * @returns {Promise<void>}
456
462
  *
457
463
  * @example
458
464
  * ```typescript
459
- * server.onMessage = async (message, sender) => {
460
- * await server.onMessage(message, sender);
461
- * console.log("Message processed from:", sender.id);
465
+ * server.onConnect = async (conn, ctx) => {
466
+ * await server.onConnect(conn, ctx);
467
+ * console.log("New user connected:", conn.id);
462
468
  * };
463
469
  * ```
464
470
  */
471
+ async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
472
+ if (ctx.request?.headers.has('x-shard-id')) {
473
+ this.onConnectShard(conn, ctx);
474
+ }
475
+ else {
476
+ await this.onConnectClient(conn, ctx);
477
+ }
478
+ }
479
+
480
+ /**
481
+ * @method onConnectShard
482
+ * @private
483
+ * @param {Party.Connection} conn - The connection object for the new shard.
484
+ * @param {Party.ConnectionContext} ctx - The context of the shard connection.
485
+ * @description Handles a new shard connection, setting up the necessary state.
486
+ * @returns {void}
487
+ */
488
+ onConnectShard(conn: Party.Connection, ctx: Party.ConnectionContext) {
489
+ // Set shard metadata in connection state
490
+ const shardId = ctx.request?.headers.get('x-shard-id') || 'unknown-shard';
491
+ conn.setState({
492
+ shard: true,
493
+ shardId,
494
+ clients: new Map() // Track clients connected through this shard
495
+ });
496
+ }
465
497
 
498
+ /**
499
+ * @method onMessage
500
+ * @async
501
+ * @param {string} message - The message received from a user or shard.
502
+ * @param {Party.Connection} sender - The connection object of the sender.
503
+ * @description Processes incoming messages, handling differently based on if sender is shard or client.
504
+ * @returns {Promise<void>}
505
+ */
466
506
  async onMessage(message: string, sender: Party.Connection) {
507
+ // Check if message is from a shard
508
+ if (sender.state && (sender.state as any).shard) {
509
+ await this.handleShardMessage(message, sender);
510
+ return;
511
+ }
512
+
513
+ // Regular client message handling
467
514
  let json
468
515
  try {
469
516
  json = JSON.parse(message)
@@ -471,16 +518,19 @@ export class Server implements Party.Server {
471
518
  catch (e) {
472
519
  return;
473
520
  }
474
- // Validate incoming messages
521
+
522
+ // Validate incoming messages
475
523
  const result = Message.safeParse(json);
476
524
  if (!result.success) {
477
525
  return;
478
526
  }
527
+
479
528
  const subRoom = await this.getSubRoom()
529
+
480
530
  // Check room guards
481
531
  const roomGuards = subRoom.constructor['_roomGuards'] || [];
482
532
  for (const guard of roomGuards) {
483
- const isAuthorized = await guard(sender, result.data.value);
533
+ const isAuthorized = await guard(sender, result.data.value, this.room);
484
534
  if (!isAuthorized) {
485
535
  return;
486
536
  }
@@ -520,6 +570,214 @@ export class Server implements Party.Server {
520
570
  }
521
571
  }
522
572
 
573
+ /**
574
+ * @method handleShardMessage
575
+ * @private
576
+ * @async
577
+ * @param {string} message - The message received from a shard.
578
+ * @param {Party.Connection} shardConnection - The connection object of the shard.
579
+ * @description Processes messages from shards, extracting client information.
580
+ * @returns {Promise<void>}
581
+ */
582
+ private async handleShardMessage(message: string, shardConnection: Party.Connection) {
583
+ let parsedMessage;
584
+ try {
585
+ parsedMessage = JSON.parse(message);
586
+ } catch (e) {
587
+ console.error("Error parsing shard message:", e);
588
+ return;
589
+ }
590
+
591
+ const shardState = shardConnection.state as any;
592
+ const clients = shardState.clients;
593
+
594
+ switch (parsedMessage.type) {
595
+ case 'shard.clientConnected':
596
+ // Handle new client connection through shard
597
+ await this.handleShardClientConnect(parsedMessage, shardConnection);
598
+ break;
599
+
600
+ case 'shard.clientMessage':
601
+ // Handle message from a client through shard
602
+ await this.handleShardClientMessage(parsedMessage, shardConnection);
603
+ break;
604
+
605
+ case 'shard.clientDisconnected':
606
+ // Handle client disconnection through shard
607
+ await this.handleShardClientDisconnect(parsedMessage, shardConnection);
608
+ break;
609
+
610
+ default:
611
+ console.warn(`Unknown shard message type: ${parsedMessage.type}`);
612
+ }
613
+ }
614
+
615
+ /**
616
+ * @method handleShardClientConnect
617
+ * @private
618
+ * @async
619
+ * @param {Object} message - The client connection message from a shard.
620
+ * @param {Party.Connection} shardConnection - The connection object of the shard.
621
+ * @description Handles a new client connection via a shard.
622
+ * @returns {Promise<void>}
623
+ */
624
+ private async handleShardClientConnect(message: any, shardConnection: Party.Connection) {
625
+ const { privateId, connectionInfo } = message;
626
+ const shardState = shardConnection.state as any;
627
+
628
+ // Create a virtual connection context for the client
629
+ const virtualContext: Party.ConnectionContext = {
630
+ request: {
631
+ headers: new Headers({
632
+ 'x-forwarded-for': connectionInfo.ip,
633
+ 'user-agent': connectionInfo.userAgent,
634
+ // Add other headers as needed
635
+ }),
636
+ method: 'GET',
637
+ url: ''
638
+ } as unknown as Party.Request
639
+ };
640
+
641
+ // Create a virtual connection for the client
642
+ const virtualConnection: Partial<Party.Connection> = {
643
+ id: privateId,
644
+ send: (data: string) => {
645
+ // Forward to the actual client through the shard
646
+ shardConnection.send(JSON.stringify({
647
+ targetClientId: privateId,
648
+ data
649
+ }));
650
+ },
651
+ state: {},
652
+ setState: (state: unknown) => {
653
+ // Store client state in the shard's client map
654
+ const clients = shardState.clients;
655
+ const currentState = clients.get(privateId) || {};
656
+ const mergedState = Object.assign({}, currentState, state as object);
657
+ clients.set(privateId, mergedState);
658
+
659
+ // Update our virtual connection's state reference
660
+ virtualConnection.state = clients.get(privateId);
661
+ return virtualConnection.state as any;
662
+ },
663
+ close: () => {
664
+ // Send close command to the shard
665
+ shardConnection.send(JSON.stringify({
666
+ type: 'shard.closeClient',
667
+ privateId
668
+ }));
669
+
670
+ // Clean up virtual connection
671
+ if (shardState.clients) {
672
+ shardState.clients.delete(privateId);
673
+ }
674
+ }
675
+ };
676
+
677
+ // Initialize the client's state in the shard state
678
+ if (!shardState.clients.has(privateId)) {
679
+ shardState.clients.set(privateId, {});
680
+ }
681
+
682
+ // Now handle this virtual connection as a regular client connection
683
+ await this.onConnectClient(virtualConnection as Party.Connection, virtualContext);
684
+ }
685
+
686
+ /**
687
+ * @method handleShardClientMessage
688
+ * @private
689
+ * @async
690
+ * @param {Object} message - The client message from a shard.
691
+ * @param {Party.Connection} shardConnection - The connection object of the shard.
692
+ * @description Handles a message from a client via a shard.
693
+ * @returns {Promise<void>}
694
+ */
695
+ private async handleShardClientMessage(message: any, shardConnection: Party.Connection) {
696
+ const { privateId, publicId, payload } = message;
697
+ const shardState = shardConnection.state as any;
698
+ const clients = shardState.clients;
699
+
700
+ // Get or create virtual connection for this client
701
+ if (!clients.has(privateId)) {
702
+ console.warn(`Received message from unknown client ${privateId}, creating virtual connection`);
703
+ clients.set(privateId, { publicId });
704
+ }
705
+
706
+ // Create a virtual connection for the client
707
+ const virtualConnection: Partial<Party.Connection> = {
708
+ id: privateId,
709
+ send: (data: string) => {
710
+ // Forward to the actual client through the shard
711
+ shardConnection.send(JSON.stringify({
712
+ targetClientId: privateId,
713
+ data
714
+ }));
715
+ },
716
+ state: clients.get(privateId),
717
+ setState: (state: unknown) => {
718
+ const currentState = clients.get(privateId) || {};
719
+ const mergedState = Object.assign({}, currentState, state as object);
720
+ clients.set(privateId, mergedState);
721
+ virtualConnection.state = clients.get(privateId);
722
+ return virtualConnection.state as any;
723
+ },
724
+ close: () => {
725
+ shardConnection.send(JSON.stringify({
726
+ type: 'shard.closeClient',
727
+ privateId
728
+ }));
729
+
730
+ if (shardState.clients) {
731
+ shardState.clients.delete(privateId);
732
+ }
733
+ }
734
+ };
735
+
736
+ // Process the payload using the regular message handler
737
+ const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload);
738
+ await this.onMessage(payloadString, virtualConnection as Party.Connection);
739
+ }
740
+
741
+ /**
742
+ * @method handleShardClientDisconnect
743
+ * @private
744
+ * @async
745
+ * @param {Object} message - The client disconnection message from a shard.
746
+ * @param {Party.Connection} shardConnection - The connection object of the shard.
747
+ * @description Handles a client disconnection via a shard.
748
+ * @returns {Promise<void>}
749
+ */
750
+ private async handleShardClientDisconnect(message: any, shardConnection: Party.Connection) {
751
+ const { privateId, publicId } = message;
752
+ const shardState = shardConnection.state as any;
753
+ const clients = shardState.clients;
754
+
755
+ // Get client state
756
+ const clientState = clients.get(privateId);
757
+ if (!clientState) {
758
+ console.warn(`Disconnection for unknown client ${privateId}`);
759
+ return;
760
+ }
761
+
762
+ // Create a virtual connection for the client one last time
763
+ const virtualConnection: Partial<Party.Connection> = {
764
+ id: privateId,
765
+ send: () => { }, // No-op since client is disconnecting
766
+ state: clientState,
767
+ setState: () => {
768
+ // No-op since client is disconnecting
769
+ return {} as any;
770
+ },
771
+ close: () => { }
772
+ };
773
+
774
+ // Handle disconnection with the regular onClose handler
775
+ await this.onClose(virtualConnection as Party.Connection);
776
+
777
+ // Clean up
778
+ clients.delete(privateId);
779
+ }
780
+
523
781
  /**
524
782
  * @method onClose
525
783
  * @async
@@ -560,12 +818,10 @@ export class Server implements Party.Server {
560
818
  await this.updateSessionConnection(privateId, false);
561
819
 
562
820
  // Broadcast user disconnection
563
- this.room.broadcast(
564
- JSON.stringify({
565
- type: "user_disconnected",
566
- value: { publicId }
567
- })
568
- );
821
+ this.broadcast({
822
+ type: "user_disconnected",
823
+ value: { publicId }
824
+ }, subRoom);
569
825
  }
570
826
 
571
827
  async onAlarm() {
@@ -578,26 +834,272 @@ export class Server implements Party.Server {
578
834
  await awaitReturn(subRoom["onError"]?.(connection, error));
579
835
  }
580
836
 
837
+ /**
838
+ * @method onRequest
839
+ * @async
840
+ * @param {Party.Request} req - The HTTP request to handle
841
+ * @description Handles HTTP requests, either directly from clients or forwarded by shards
842
+ * @returns {Promise<Response>} The response to return to the client
843
+ */
581
844
  async onRequest(req: Party.Request) {
582
- const subRoom = await this.getSubRoom()
583
- const res = (body: any, status: number) => {
584
- return new Response(JSON.stringify(body), { status });
845
+ // Check if the request is coming from a shard
846
+ const isFromShard = req.headers.has('x-forwarded-by-shard');
847
+ const shardId = req.headers.get('x-shard-id');
848
+ const res = new ServerResponse([
849
+ createCorsInterceptor()
850
+ ]);
851
+
852
+ if (isFromShard) {
853
+ return this.handleShardRequest(req, res, shardId);
585
854
  }
855
+
856
+ // Handle regular client request
857
+ return this.handleDirectRequest(req, res);
858
+ }
859
+
860
+ /**
861
+ * @method handleDirectRequest
862
+ * @private
863
+ * @async
864
+ * @param {Party.Request} req - The HTTP request received directly from a client
865
+ * @description Processes requests received directly from clients
866
+ * @returns {Promise<Response>} The response to return to the client
867
+ */
868
+ private async handleDirectRequest(req: Party.Request, res: ServerResponse): Promise<Response> {
869
+ const subRoom = await this.getSubRoom();
586
870
  if (!subRoom) {
587
- return res({
588
- error: "Not found"
589
- }, 404);
871
+ return res.notFound();
590
872
  }
591
873
 
592
- const response = await awaitReturn(subRoom["onRequest"]?.(req, this.room));
593
- if (!response) {
594
- return res({
595
- error: "Not found"
596
- }, 404);
597
- }
598
- if (response instanceof Response) {
874
+ // First try to match using the registered @Request handlers
875
+ const response = await this.tryMatchRequestHandler(req, res, subRoom);
876
+ if (response) {
599
877
  return response;
600
878
  }
601
- return res(response, 200);
879
+
880
+ // Fall back to the legacy onRequest method if no handler matched
881
+ const legacyResponse = await awaitReturn(subRoom["onRequest"]?.(req, res));
882
+ if (!legacyResponse) {
883
+ return res.notFound();
884
+ }
885
+ if (legacyResponse instanceof Response) {
886
+ return legacyResponse;
887
+ }
888
+ return res.success(legacyResponse);
889
+ }
890
+
891
+ /**
892
+ * @method tryMatchRequestHandler
893
+ * @private
894
+ * @async
895
+ * @param {Party.Request} req - The HTTP request to handle
896
+ * @param {Object} subRoom - The room instance
897
+ * @description Attempts to match the request to a registered @Request handler
898
+ * @returns {Promise<Response | null>} The response or null if no handler matched
899
+ */
900
+ private async tryMatchRequestHandler(req: Party.Request, res: ServerResponse, subRoom: any): Promise<Response | null> {
901
+ const requestHandlers = subRoom.constructor["_requestMetadata"];
902
+ if (!requestHandlers) {
903
+ return null;
904
+ }
905
+
906
+ const url = new URL(req.url);
907
+ const method = req.method;
908
+ let pathname = url.pathname;
909
+
910
+ pathname = '/' + pathname.split('/').slice(4).join('/');
911
+
912
+ // Check each registered handler
913
+ for (const [routeKey, handler] of requestHandlers.entries()) {
914
+ const firstColonIndex = routeKey.indexOf(':');
915
+ const handlerMethod = routeKey.substring(0, firstColonIndex);
916
+ const handlerPath = routeKey.substring(firstColonIndex + 1);
917
+
918
+ // Check if method matches
919
+ if (handlerMethod !== method) {
920
+ continue;
921
+ }
922
+
923
+ // Simple path matching (could be enhanced with path params)
924
+ if (this.pathMatches(pathname, handlerPath)) {
925
+ // Extract path params if any
926
+ const params = this.extractPathParams(pathname, handlerPath);
927
+ // Check request guards if they exist
928
+ const guards = subRoom.constructor['_actionGuards']?.get(handler.key) || [];
929
+ for (const guard of guards) {
930
+ const isAuthorized = await guard(null, req, this.room);
931
+ if (isAuthorized instanceof Response) {
932
+ return isAuthorized;
933
+ }
934
+ if (!isAuthorized) {
935
+ return res.notPermitted();
936
+ }
937
+ }
938
+
939
+ // Validate request body if needed
940
+ let bodyData = null;
941
+ if (handler.bodyValidation && ['POST', 'PUT', 'PATCH'].includes(method)) {
942
+ try {
943
+ const contentType = req.headers.get('content-type') || '';
944
+ if (contentType.includes('application/json')) {
945
+ const body = await req.json();
946
+ const validation = handler.bodyValidation.safeParse(body);
947
+ if (!validation.success) {
948
+ return res.badRequest("Invalid request body", {
949
+ details: validation.error
950
+ });
951
+ }
952
+ bodyData = validation.data;
953
+ }
954
+ } catch (error) {
955
+ return res.badRequest("Failed to parse request body");
956
+ }
957
+ }
958
+
959
+ // Execute the handler method
960
+ try {
961
+ req['data'] = bodyData;
962
+ req['params'] = params;
963
+ const result = await awaitReturn(
964
+ subRoom[handler.key](req, res)
965
+ );
966
+
967
+ if (result instanceof Response) {
968
+ return result;
969
+ }
970
+
971
+ return res.success(result);
972
+ } catch (error) {
973
+ console.error('Error executing request handler:', error);
974
+ return res.serverError();
975
+ }
976
+ }
977
+ }
978
+
979
+ return null;
980
+ }
981
+
982
+ /**
983
+ * @method pathMatches
984
+ * @private
985
+ * @param {string} requestPath - The path from the request
986
+ * @param {string} handlerPath - The path pattern from the handler
987
+ * @description Checks if a request path matches a handler path pattern
988
+ * @returns {boolean} True if the paths match
989
+ */
990
+ private pathMatches(requestPath: string, handlerPath: string): boolean {
991
+ // Convert handler path pattern to regex
992
+ // Replace :param with named capture groups
993
+ const pathRegexString = handlerPath
994
+ .replace(/\//g, '\\/') // Escape slashes
995
+ .replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
996
+
997
+ const pathRegex = new RegExp(`^${pathRegexString}$`);
998
+ return pathRegex.test(requestPath);
999
+ }
1000
+
1001
+ /**
1002
+ * @method extractPathParams
1003
+ * @private
1004
+ * @param {string} requestPath - The path from the request
1005
+ * @param {string} handlerPath - The path pattern from the handler
1006
+ * @description Extracts path parameters from the request path based on the handler pattern
1007
+ * @returns {Object} An object containing the path parameters
1008
+ */
1009
+ private extractPathParams(requestPath: string, handlerPath: string): Record<string, string> {
1010
+ const params: Record<string, string> = {};
1011
+
1012
+ // Extract parameter names from handler path
1013
+ const paramNames: string[] = [];
1014
+ handlerPath.split('/').forEach(segment => {
1015
+ if (segment.startsWith(':')) {
1016
+ paramNames.push(segment.substring(1));
1017
+ }
1018
+ });
1019
+
1020
+ // Extract parameter values from request path
1021
+ const pathRegexString = handlerPath
1022
+ .replace(/\//g, '\\/') // Escape slashes
1023
+ .replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
1024
+
1025
+ const pathRegex = new RegExp(`^${pathRegexString}$`);
1026
+ const matches = requestPath.match(pathRegex);
1027
+
1028
+ if (matches && matches.length > 1) {
1029
+ // Skip the first match (the full string)
1030
+ for (let i = 0; i < paramNames.length; i++) {
1031
+ params[paramNames[i]] = matches[i + 1];
1032
+ }
1033
+ }
1034
+
1035
+ return params;
1036
+ }
1037
+
1038
+ /**
1039
+ * @method handleShardRequest
1040
+ * @private
1041
+ * @async
1042
+ * @param {Party.Request} req - The HTTP request forwarded by a shard
1043
+ * @param {string | null} shardId - The ID of the shard that forwarded the request
1044
+ * @description Processes requests forwarded by shards, preserving client context
1045
+ * @returns {Promise<Response>} The response to return to the shard (which will forward it to the client)
1046
+ */
1047
+ private async handleShardRequest(req: Party.Request, res: ServerResponse, shardId: string | null): Promise<Response> {
1048
+ const subRoom = await this.getSubRoom();
1049
+
1050
+ if (!subRoom) {
1051
+ return res.notFound();
1052
+ }
1053
+
1054
+ // Create a context that preserves original client information
1055
+ const originalClientIp = req.headers.get('x-original-client-ip');
1056
+ const enhancedReq = this.createEnhancedRequest(req, originalClientIp);
1057
+
1058
+ try {
1059
+ // First try to match using the registered @Request handlers
1060
+ const response = await this.tryMatchRequestHandler(enhancedReq, res, subRoom);
1061
+ if (response) {
1062
+ return response;
1063
+ }
1064
+
1065
+ // Fall back to the legacy onRequest handler
1066
+ const legacyResponse = await awaitReturn(subRoom["onRequest"]?.(enhancedReq, res));
1067
+
1068
+ if (!legacyResponse) {
1069
+ return res.notFound();
1070
+ }
1071
+
1072
+ if (legacyResponse instanceof Response) {
1073
+ return legacyResponse;
1074
+ }
1075
+
1076
+ return res.success(legacyResponse);
1077
+ } catch (error) {
1078
+ console.error(`Error processing request from shard ${shardId}:`, error);
1079
+ return res.serverError();
1080
+ }
1081
+ }
1082
+
1083
+ /**
1084
+ * @method createEnhancedRequest
1085
+ * @private
1086
+ * @param {Party.Request} originalReq - The original request received from the shard
1087
+ * @param {string | null} originalClientIp - The original client IP, if available
1088
+ * @description Creates an enhanced request object that preserves the original client context
1089
+ * @returns {Party.Request} The enhanced request object
1090
+ */
1091
+ private createEnhancedRequest(originalReq: Party.Request, originalClientIp: string | null): Party.Request {
1092
+ // Clone the original request to avoid mutating it
1093
+ const clonedReq = originalReq.clone();
1094
+
1095
+ // Add a custom property to the request to indicate it came via a shard
1096
+ (clonedReq as any).viaShard = true;
1097
+
1098
+ // If we have the original client IP, we can use it for things like rate limiting or geolocation
1099
+ if (originalClientIp) {
1100
+ (clonedReq as any).originalClientIp = originalClientIp;
1101
+ }
1102
+
1103
+ return clonedReq;
602
1104
  }
603
1105
  }