@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/dist/index.d.ts +365 -22
- package/dist/index.js +1659 -71
- 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 +234 -2
- package/src/decorators.ts +34 -2
- package/src/index.ts +5 -1
- package/src/interfaces.ts +13 -0
- package/src/jwt.ts +217 -0
- package/src/mock.ts +39 -3
- package/src/request/cors.ts +54 -0
- package/src/request/response.ts +228 -0
- package/src/server.ts +588 -86
- 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
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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.
|
|
233
|
-
|
|
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({
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
456
|
+
* @method onConnect
|
|
451
457
|
* @async
|
|
452
|
-
* @param {
|
|
453
|
-
* @param {Party.
|
|
454
|
-
* @description
|
|
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.
|
|
460
|
-
* await server.
|
|
461
|
-
* console.log("
|
|
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
|
-
|
|
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.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
583
|
-
const
|
|
584
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
}
|