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