@signe/room 2.10.0 → 3.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/CHANGELOG.md +7 -0
- package/dist/chunk-EUXUH3YW.js +15 -0
- package/dist/chunk-EUXUH3YW.js.map +1 -0
- package/dist/cloudflare/index.d.ts +71 -0
- package/dist/cloudflare/index.js +320 -0
- package/dist/cloudflare/index.js.map +1 -0
- package/dist/index.d.ts +66 -187
- package/dist/index.js +727 -106
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +164 -0
- package/dist/node/index.js +786 -0
- package/dist/node/index.js.map +1 -0
- package/dist/party-dNs-hqkq.d.ts +175 -0
- package/examples/cloudflare/README.md +62 -0
- package/examples/cloudflare/node_modules/.bin/tsc +17 -0
- package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
- package/examples/cloudflare/package.json +24 -0
- package/examples/cloudflare/public/index.html +443 -0
- package/examples/cloudflare/src/index.ts +28 -0
- package/examples/cloudflare/src/room.ts +44 -0
- package/examples/cloudflare/tsconfig.json +10 -0
- package/examples/cloudflare/wrangler.jsonc +25 -0
- package/examples/node/README.md +57 -0
- package/examples/node/node_modules/.bin/tsc +17 -0
- package/examples/node/node_modules/.bin/tsserver +17 -0
- package/examples/node/node_modules/.bin/tsx +17 -0
- package/examples/node/package.json +23 -0
- package/examples/node/public/index.html +443 -0
- package/examples/node/room.ts +44 -0
- package/examples/node/server.sqlite.ts +52 -0
- package/examples/node/server.ts +51 -0
- package/examples/node/tsconfig.json +10 -0
- package/examples/node-game/README.md +66 -0
- package/examples/node-game/package.json +23 -0
- package/examples/node-game/public/index.html +705 -0
- package/examples/node-game/room.ts +145 -0
- package/examples/node-game/server.sqlite.ts +54 -0
- package/examples/node-game/server.ts +53 -0
- package/examples/node-game/tsconfig.json +10 -0
- package/examples/node-shard/README.md +32 -0
- package/examples/node-shard/dev.ts +39 -0
- package/examples/node-shard/package.json +24 -0
- package/examples/node-shard/public/index.html +777 -0
- package/examples/node-shard/room-server.ts +68 -0
- package/examples/node-shard/room.ts +105 -0
- package/examples/node-shard/shared.ts +6 -0
- package/examples/node-shard/tsconfig.json +14 -0
- package/examples/node-shard/world-server.ts +169 -0
- package/package.json +14 -5
- package/readme.md +371 -4
- package/src/cloudflare/index.ts +474 -0
- package/src/jwt.ts +1 -5
- package/src/mock.ts +29 -7
- package/src/node/index.ts +1112 -0
- package/src/server.ts +600 -51
- package/src/session.guard.ts +6 -2
- package/src/shard.ts +91 -23
- package/src/storage.ts +29 -5
- package/src/testing.ts +4 -3
- package/src/types/party.ts +4 -1
- package/src/world.guard.ts +23 -4
- package/src/world.ts +121 -21
- package/examples/game/.vscode/launch.json +0 -11
- package/examples/game/.vscode/settings.json +0 -11
- package/examples/game/README.md +0 -40
- package/examples/game/app/client.tsx +0 -15
- package/examples/game/app/components/Admin.tsx +0 -1089
- package/examples/game/app/components/Room.tsx +0 -162
- package/examples/game/app/styles.css +0 -31
- package/examples/game/package-lock.json +0 -225
- package/examples/game/package.json +0 -20
- package/examples/game/party/game.room.ts +0 -32
- package/examples/game/party/server.ts +0 -10
- package/examples/game/party/shard.ts +0 -5
- package/examples/game/partykit.json +0 -14
- package/examples/game/public/favicon.ico +0 -0
- package/examples/game/public/index.html +0 -27
- package/examples/game/public/normalize.css +0 -351
- package/examples/game/shared/room.schema.ts +0 -14
- package/examples/game/tsconfig.json +0 -109
package/src/session.guard.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type * as Party from "./types/party";
|
|
2
2
|
|
|
3
|
+
function getPrivateId(sender: Party.Connection) {
|
|
4
|
+
return sender.sessionId || sender.id;
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* @description Factory function that creates a session guard with access to room storage
|
|
5
9
|
* @param {Party.Storage} storage - The room storage instance
|
|
@@ -29,7 +33,7 @@ export function createRequireSessionGuard(storage: Party.Storage) {
|
|
|
29
33
|
|
|
30
34
|
try {
|
|
31
35
|
// Check if session exists in storage
|
|
32
|
-
const session = await storage.get(`session:${sender
|
|
36
|
+
const session = await storage.get(`session:${getPrivateId(sender)}`);
|
|
33
37
|
|
|
34
38
|
// Return false if no session found
|
|
35
39
|
if (!session) {
|
|
@@ -88,7 +92,7 @@ export const requireSession = async (sender: Party.Connection, value: any, room:
|
|
|
88
92
|
|
|
89
93
|
try {
|
|
90
94
|
// Check if session exists in storage
|
|
91
|
-
const session = await room.storage.get(`session:${sender
|
|
95
|
+
const session = await room.storage.get(`session:${getPrivateId(sender)}`);
|
|
92
96
|
|
|
93
97
|
// Return false if no session found
|
|
94
98
|
if (!session) {
|
package/src/shard.ts
CHANGED
|
@@ -15,18 +15,44 @@ export interface ShardOptions {
|
|
|
15
15
|
|
|
16
16
|
export class Shard {
|
|
17
17
|
ws: PartyWebSocket;
|
|
18
|
-
connectionMap = new Map<string, Party.Connection
|
|
18
|
+
connectionMap = new Map<string, Set<Party.Connection>>(); // Map privateId -> active connections
|
|
19
19
|
mainServerStub: any;
|
|
20
20
|
worldUrl: string | null = null;
|
|
21
|
-
worldId: string
|
|
21
|
+
worldId: string;
|
|
22
22
|
lastReportedConnections: number = 0;
|
|
23
23
|
statsInterval: number = 30000;
|
|
24
24
|
statsIntervalId: any = null;
|
|
25
25
|
|
|
26
|
-
constructor(private room: Party.Room) {
|
|
26
|
+
constructor(private room: Party.Room, options: ShardOptions = {}) {
|
|
27
|
+
this.worldUrl = options.worldUrl ?? null;
|
|
28
|
+
this.worldId = options.worldId
|
|
29
|
+
?? this.getWorldIdFromShardId(room.id)
|
|
30
|
+
?? this.getEnvString('WORLD_ID')
|
|
31
|
+
?? this.getEnvString('SIGNE_WORLD_ID')
|
|
32
|
+
?? 'world-default';
|
|
33
|
+
this.statsInterval = options.statsInterval ?? this.statsInterval;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private getPrivateId(conn: Party.Connection) {
|
|
37
|
+
return conn.sessionId || conn.id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private getEnvString(key: string) {
|
|
41
|
+
const value = this.room.env?.[key];
|
|
42
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private getRoomIdFromShardId(shardId: string) {
|
|
46
|
+
return shardId.split(':')[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getWorldIdFromShardId(shardId: string) {
|
|
50
|
+
const parts = shardId.split(':');
|
|
51
|
+
return parts.length >= 3 ? parts[1] : undefined;
|
|
52
|
+
}
|
|
27
53
|
|
|
28
54
|
async onStart() {
|
|
29
|
-
const roomId = this.room.id
|
|
55
|
+
const roomId = this.getRoomIdFromShardId(this.room.id);
|
|
30
56
|
const roomStub = this.room.context.parties.main.get(roomId);
|
|
31
57
|
if (!roomStub) {
|
|
32
58
|
console.warn('No room room stub found in main party context');
|
|
@@ -36,7 +62,9 @@ export class Shard {
|
|
|
36
62
|
this.mainServerStub = roomStub;
|
|
37
63
|
this.ws = await roomStub.socket({
|
|
38
64
|
headers: {
|
|
39
|
-
'x-shard-id': this.room.id
|
|
65
|
+
'x-shard-id': this.room.id,
|
|
66
|
+
'x-shard-world-id': this.worldId,
|
|
67
|
+
'x-access-shard': this.room.env.SHARD_SECRET as string
|
|
40
68
|
}
|
|
41
69
|
}) as unknown as PartyWebSocket;
|
|
42
70
|
|
|
@@ -45,13 +73,25 @@ export class Shard {
|
|
|
45
73
|
try {
|
|
46
74
|
const message = JSON.parse(event.data);
|
|
47
75
|
|
|
76
|
+
if (message.type === 'shard.closeClient' && message.privateId) {
|
|
77
|
+
const clientConnections = this.connectionMap.get(message.privateId);
|
|
78
|
+
if (clientConnections?.size) {
|
|
79
|
+
for (const clientConn of [...clientConnections]) {
|
|
80
|
+
clientConn.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
// If the message is directed to a specific client, forward it
|
|
49
87
|
if (message.targetClientId) {
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
88
|
+
const clientConnections = this.connectionMap.get(message.targetClientId);
|
|
89
|
+
if (clientConnections?.size) {
|
|
52
90
|
// Remove the routing information before forwarding
|
|
53
91
|
delete message.targetClientId;
|
|
54
|
-
clientConn
|
|
92
|
+
for (const clientConn of clientConnections) {
|
|
93
|
+
clientConn.send(message.data);
|
|
94
|
+
}
|
|
55
95
|
}
|
|
56
96
|
} else {
|
|
57
97
|
// Broadcast to all clients if no specific target
|
|
@@ -62,12 +102,12 @@ export class Shard {
|
|
|
62
102
|
}
|
|
63
103
|
});
|
|
64
104
|
|
|
65
|
-
await this.updateWorldStats();
|
|
105
|
+
await this.updateWorldStats(true);
|
|
66
106
|
this.startPeriodicStatsUpdates();
|
|
67
107
|
}
|
|
68
108
|
|
|
69
109
|
private startPeriodicStatsUpdates() {
|
|
70
|
-
if (!this.
|
|
110
|
+
if (this.statsInterval <= 0 || !this.room.context.parties.world) {
|
|
71
111
|
return;
|
|
72
112
|
}
|
|
73
113
|
|
|
@@ -76,10 +116,11 @@ export class Shard {
|
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
this.statsIntervalId = setInterval(() => {
|
|
79
|
-
this.updateWorldStats().catch(error => {
|
|
119
|
+
this.updateWorldStats(true).catch(error => {
|
|
80
120
|
console.error('Error in periodic stats update:', error);
|
|
81
121
|
});
|
|
82
122
|
}, this.statsInterval);
|
|
123
|
+
this.statsIntervalId?.unref?.();
|
|
83
124
|
}
|
|
84
125
|
|
|
85
126
|
private stopPeriodicStatsUpdates() {
|
|
@@ -90,8 +131,12 @@ export class Shard {
|
|
|
90
131
|
}
|
|
91
132
|
|
|
92
133
|
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
|
|
134
|
+
const privateId = this.getPrivateId(conn);
|
|
135
|
+
|
|
93
136
|
// Store connection mapping
|
|
94
|
-
this.connectionMap.
|
|
137
|
+
const connections = this.connectionMap.get(privateId) ?? new Set<Party.Connection>();
|
|
138
|
+
connections.add(conn);
|
|
139
|
+
this.connectionMap.set(privateId, connections);
|
|
95
140
|
|
|
96
141
|
// Capture all headers and request information
|
|
97
142
|
const headers: Record<string, string> = {};
|
|
@@ -111,7 +156,7 @@ export class Shard {
|
|
|
111
156
|
// Notify the main server about the new connection with complete connection metadata
|
|
112
157
|
this.ws.send(JSON.stringify({
|
|
113
158
|
type: 'shard.clientConnected',
|
|
114
|
-
privateId
|
|
159
|
+
privateId,
|
|
115
160
|
requestInfo
|
|
116
161
|
}));
|
|
117
162
|
|
|
@@ -126,7 +171,7 @@ export class Shard {
|
|
|
126
171
|
// Wrap the original message with sender information
|
|
127
172
|
const wrappedMessage = JSON.stringify({
|
|
128
173
|
type: 'shard.clientMessage',
|
|
129
|
-
privateId: sender
|
|
174
|
+
privateId: this.getPrivateId(sender),
|
|
130
175
|
publicId: (sender.state as any)?.publicId,
|
|
131
176
|
payload: parsedMessage
|
|
132
177
|
});
|
|
@@ -139,28 +184,48 @@ export class Shard {
|
|
|
139
184
|
}
|
|
140
185
|
|
|
141
186
|
onClose(conn: Party.Connection) {
|
|
187
|
+
const privateId = this.getPrivateId(conn);
|
|
188
|
+
|
|
142
189
|
// Remove connection from the map
|
|
143
|
-
this.connectionMap.
|
|
190
|
+
const connections = this.connectionMap.get(privateId);
|
|
191
|
+
connections?.delete(conn);
|
|
192
|
+
|
|
193
|
+
if (connections?.size) {
|
|
194
|
+
this.updateWorldStats();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.connectionMap.delete(privateId);
|
|
144
199
|
|
|
145
200
|
// Notify main server about disconnection
|
|
146
201
|
this.ws.send(JSON.stringify({
|
|
147
202
|
type: 'shard.clientDisconnected',
|
|
148
|
-
privateId
|
|
203
|
+
privateId,
|
|
149
204
|
publicId: (conn.state as any)?.publicId
|
|
150
205
|
}));
|
|
151
206
|
|
|
152
207
|
this.updateWorldStats();
|
|
153
208
|
}
|
|
154
209
|
|
|
155
|
-
async updateWorldStats(): Promise<boolean> {
|
|
156
|
-
const currentConnections = this.connectionMap.
|
|
210
|
+
async updateWorldStats(force = false): Promise<boolean> {
|
|
211
|
+
const currentConnections = Array.from(this.connectionMap.values())
|
|
212
|
+
.reduce((total, connections) => total + connections.size, 0);
|
|
157
213
|
|
|
158
|
-
if (currentConnections === this.lastReportedConnections) {
|
|
214
|
+
if (!force && currentConnections === this.lastReportedConnections) {
|
|
159
215
|
return true;
|
|
160
216
|
}
|
|
161
217
|
|
|
162
218
|
try {
|
|
163
|
-
const
|
|
219
|
+
const worldParty = this.room.context.parties.world;
|
|
220
|
+
if (!worldParty) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const worldRoom = worldParty.get(this.worldId);
|
|
225
|
+
if (!worldRoom?.fetch) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
164
229
|
const response = await worldRoom.fetch('/update-shard', {
|
|
165
230
|
method: 'POST',
|
|
166
231
|
headers: {
|
|
@@ -169,6 +234,7 @@ export class Shard {
|
|
|
169
234
|
},
|
|
170
235
|
body: JSON.stringify({
|
|
171
236
|
shardId: this.room.id,
|
|
237
|
+
worldId: this.worldId,
|
|
172
238
|
connections: currentConnections
|
|
173
239
|
})
|
|
174
240
|
});
|
|
@@ -219,7 +285,9 @@ export class Shard {
|
|
|
219
285
|
|
|
220
286
|
// Add shard identification
|
|
221
287
|
headers.set('x-shard-id', this.room.id);
|
|
288
|
+
headers.set('x-shard-world-id', this.worldId);
|
|
222
289
|
headers.set('x-forwarded-by-shard', 'true');
|
|
290
|
+
headers.set('x-access-shard', this.room.env.SHARD_SECRET as string);
|
|
223
291
|
|
|
224
292
|
// Client IP tracking for the main server
|
|
225
293
|
const clientIp = req.headers.get('x-forwarded-for') || 'unknown';
|
|
@@ -234,7 +302,7 @@ export class Shard {
|
|
|
234
302
|
body
|
|
235
303
|
};
|
|
236
304
|
// Forward the request to the main server
|
|
237
|
-
const response = await this.mainServerStub.fetch(path, requestInit);
|
|
305
|
+
const response = await this.mainServerStub.fetch(path + url.search, requestInit);
|
|
238
306
|
return response;
|
|
239
307
|
} catch (error) {
|
|
240
308
|
return response(500, { error: 'Error forwarding request' });
|
|
@@ -247,8 +315,8 @@ export class Shard {
|
|
|
247
315
|
* @description Executed periodically, used to perform maintenance tasks
|
|
248
316
|
*/
|
|
249
317
|
async onAlarm() {
|
|
250
|
-
await this.updateWorldStats();
|
|
318
|
+
await this.updateWorldStats(true);
|
|
251
319
|
}
|
|
252
320
|
}
|
|
253
321
|
|
|
254
|
-
Shard satisfies Party.Worker;
|
|
322
|
+
Shard satisfies Party.Worker;
|
package/src/storage.ts
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
export class Storage {
|
|
2
2
|
private memory = new Map();
|
|
3
|
-
async put(key, value) {
|
|
4
|
-
|
|
3
|
+
async put(key, value?) {
|
|
4
|
+
if (typeof key === "string") {
|
|
5
|
+
this.memory.set(key, value);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
for (const [entryKey, entryValue] of Object.entries(key)) {
|
|
10
|
+
this.memory.set(entryKey, entryValue);
|
|
11
|
+
}
|
|
5
12
|
}
|
|
6
13
|
async get(key) {
|
|
7
14
|
return this.memory.get(key);
|
|
8
15
|
}
|
|
9
16
|
async delete(key) {
|
|
10
|
-
|
|
17
|
+
if (Array.isArray(key)) {
|
|
18
|
+
let deleted = 0;
|
|
19
|
+
for (const entryKey of key) {
|
|
20
|
+
if (this.memory.delete(entryKey)) {
|
|
21
|
+
deleted += 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return deleted;
|
|
25
|
+
}
|
|
26
|
+
return this.memory.delete(key);
|
|
11
27
|
}
|
|
12
|
-
async list() {
|
|
13
|
-
|
|
28
|
+
async list(options?: { prefix?: string }) {
|
|
29
|
+
if (!options?.prefix) {
|
|
30
|
+
return this.memory;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return new Map(
|
|
34
|
+
Array.from(this.memory.entries()).filter(([key]) =>
|
|
35
|
+
String(key).startsWith(options.prefix!)
|
|
36
|
+
)
|
|
37
|
+
);
|
|
14
38
|
}
|
|
15
39
|
}
|
package/src/testing.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { Shard } from "./shard"
|
|
|
30
30
|
export async function testRoom(Room, options: {
|
|
31
31
|
hibernate?: boolean,
|
|
32
32
|
shard?: boolean,
|
|
33
|
+
id?: string,
|
|
33
34
|
env?: Record<string, string>,
|
|
34
35
|
parties?: Record<string, (io: any) => any>,
|
|
35
36
|
partyFn?: (io: any) => any
|
|
@@ -42,7 +43,7 @@ export async function testRoom(Room, options: {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const isShard = options.shard || false
|
|
45
|
-
const io = new ServerIo(Room.path, isShard ? {
|
|
46
|
+
const io = new ServerIo(options.id ?? Room.path, isShard ? {
|
|
46
47
|
parties: {
|
|
47
48
|
game: createServer,
|
|
48
49
|
...(options.parties || {})
|
|
@@ -92,7 +93,7 @@ export async function testRoom(Room, options: {
|
|
|
92
93
|
return client
|
|
93
94
|
},
|
|
94
95
|
getServerUser: async (client: any, prop = 'users') => {
|
|
95
|
-
const privateId = client.conn.id;
|
|
96
|
+
const privateId = client.conn.sessionId || client.conn.id;
|
|
96
97
|
const session = await (server as Server).getSession(privateId);
|
|
97
98
|
return (server as any).subRoom[prop]()[session?.publicId];
|
|
98
99
|
}
|
|
@@ -114,4 +115,4 @@ export async function request(room: Server | Shard, path: string, options: {
|
|
|
114
115
|
|
|
115
116
|
export function tick(ms: number = 0) {
|
|
116
117
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
117
|
-
}
|
|
118
|
+
}
|
package/src/types/party.ts
CHANGED
|
@@ -150,6 +150,9 @@ import type {
|
|
|
150
150
|
export type Connection<TState = unknown> = WebSocket & {
|
|
151
151
|
/** Connection identifier */
|
|
152
152
|
id: string;
|
|
153
|
+
|
|
154
|
+
/** Stable private session identifier shared by reconnects or multiple tabs. */
|
|
155
|
+
sessionId?: string;
|
|
153
156
|
|
|
154
157
|
/** @deprecated You can access the socket properties directly on the connection*/
|
|
155
158
|
socket: WebSocket;
|
|
@@ -548,4 +551,4 @@ import type {
|
|
|
548
551
|
export type PartyKitConnection = Connection;
|
|
549
552
|
|
|
550
553
|
/** @deprecated Use `Party.ServerOptions` instead */
|
|
551
|
-
export type PartyServerOptions = ServerOptions;
|
|
554
|
+
export type PartyServerOptions = ServerOptions;
|
package/src/world.guard.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import * as Party from "./types/party";
|
|
2
2
|
import { JWTAuth } from "./jwt";
|
|
3
|
-
import { response } from "./utils";
|
|
4
3
|
|
|
5
|
-
export const guardManageWorld = async (_, req: Party.Request, room: Party.Room): Promise<boolean> => {
|
|
4
|
+
export const guardManageWorld = async (_: unknown, req: Party.Request, room: Party.Room): Promise<boolean> => {
|
|
6
5
|
const tokenShard = req.headers.get("x-access-shard");
|
|
7
6
|
if (tokenShard) {
|
|
8
7
|
if (tokenShard !== room.env.SHARD_SECRET) {
|
|
@@ -11,7 +10,7 @@ export const guardManageWorld = async (_, req: Party.Request, room: Party.Room):
|
|
|
11
10
|
return true
|
|
12
11
|
}
|
|
13
12
|
const url = new URL(req.url);
|
|
14
|
-
const token = req
|
|
13
|
+
const token = getAuthToken(req, url);
|
|
15
14
|
if (!token) {
|
|
16
15
|
return false;
|
|
17
16
|
}
|
|
@@ -21,8 +20,28 @@ export const guardManageWorld = async (_, req: Party.Request, room: Party.Room):
|
|
|
21
20
|
if (!payload) {
|
|
22
21
|
return false;
|
|
23
22
|
}
|
|
23
|
+
if (!canAccessWorld(payload, room.id)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
24
26
|
} catch (error) {
|
|
25
27
|
return false;
|
|
26
28
|
}
|
|
27
29
|
return true;
|
|
28
|
-
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getAuthToken(req: Party.Request, url: URL) {
|
|
33
|
+
const authorization = req.headers.get("Authorization");
|
|
34
|
+
if (authorization?.startsWith("Bearer ")) {
|
|
35
|
+
return authorization.slice("Bearer ".length).trim();
|
|
36
|
+
}
|
|
37
|
+
return authorization ?? url.searchParams.get("world-auth-token");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canAccessWorld(payload: Record<string, unknown>, worldId: string) {
|
|
41
|
+
const worlds = payload.worlds;
|
|
42
|
+
if (!Array.isArray(worlds)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return worlds.some((world) => world === "*" || world === worldId);
|
|
47
|
+
}
|
package/src/world.ts
CHANGED
|
@@ -27,11 +27,14 @@ const RoomConfigSchema = z.object({
|
|
|
27
27
|
const RegisterShardSchema = z.object({
|
|
28
28
|
shardId: z.string(),
|
|
29
29
|
roomId: z.string(),
|
|
30
|
+
worldId: z.string().optional(),
|
|
30
31
|
url: z.string().url(),
|
|
31
32
|
maxConnections: z.number().int().positive(),
|
|
32
33
|
});
|
|
33
34
|
|
|
34
35
|
const UpdateShardStatsSchema = z.object({
|
|
36
|
+
shardId: z.string(),
|
|
37
|
+
worldId: z.string().optional(),
|
|
35
38
|
connections: z.number().int().min(0),
|
|
36
39
|
status: z.enum(['active', 'maintenance', 'draining']).optional(),
|
|
37
40
|
});
|
|
@@ -59,6 +62,7 @@ class RoomConfig {
|
|
|
59
62
|
class ShardInfo {
|
|
60
63
|
@id() id: string;
|
|
61
64
|
@sync() roomId = signal("");
|
|
65
|
+
@sync() worldId = signal("");
|
|
62
66
|
@sync() url = signal("");
|
|
63
67
|
@sync({
|
|
64
68
|
persist: false
|
|
@@ -95,8 +99,7 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
95
99
|
if (!SHARD_SECRET) {
|
|
96
100
|
throw new Error("SHARD_SECRET env variable is not set");
|
|
97
101
|
}
|
|
98
|
-
|
|
99
|
-
//setTimeout(() => this.cleanupInactiveShards(), 60000);
|
|
102
|
+
this.scheduleInactiveShardCleanup();
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
async onJoin(user: any, conn: Party.Connection, ctx: Party.ConnectionContext) {
|
|
@@ -115,6 +118,15 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
// Helper methods
|
|
121
|
+
private getWorldId() {
|
|
122
|
+
return this.room.id;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private scheduleInactiveShardCleanup() {
|
|
126
|
+
const timeoutId = setTimeout(() => this.cleanupInactiveShards(), 60000);
|
|
127
|
+
timeoutId?.unref?.();
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
private cleanupInactiveShards() {
|
|
119
131
|
const now = Date.now();
|
|
120
132
|
const timeout = 5 * 60 * 1000; // 5 minutes timeout
|
|
@@ -130,7 +142,15 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
130
142
|
});
|
|
131
143
|
|
|
132
144
|
// Schedule next cleanup
|
|
133
|
-
|
|
145
|
+
this.scheduleInactiveShardCleanup();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private removeShard(shardId: string) {
|
|
149
|
+
delete this.shards()[shardId];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private shouldCompleteDrain(shard: ShardInfo) {
|
|
153
|
+
return shard.status() === 'draining' && shard.currentConnections() === 0;
|
|
134
154
|
}
|
|
135
155
|
|
|
136
156
|
// Actions
|
|
@@ -139,8 +159,19 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
139
159
|
method: 'POST',
|
|
140
160
|
})
|
|
141
161
|
@Guard([guardManageWorld])
|
|
142
|
-
async registerRoom(req: Party.Request) {
|
|
143
|
-
const
|
|
162
|
+
async registerRoom(req: Party.Request, res?: ServerResponse) {
|
|
163
|
+
const parseResult = RoomConfigSchema.safeParse(await req.json());
|
|
164
|
+
if (!parseResult.success) {
|
|
165
|
+
return res?.badRequest("Invalid room configuration", {
|
|
166
|
+
details: parseResult.error
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const roomConfig = parseResult.data;
|
|
171
|
+
if (roomConfig.maxShards !== undefined && roomConfig.minShards > roomConfig.maxShards) {
|
|
172
|
+
return res?.badRequest("minShards cannot be greater than maxShards");
|
|
173
|
+
}
|
|
174
|
+
|
|
144
175
|
const roomId = roomConfig.name;
|
|
145
176
|
|
|
146
177
|
if (!this.rooms()[roomId]) {
|
|
@@ -170,6 +201,8 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
170
201
|
room.minShards.set(roomConfig.minShards);
|
|
171
202
|
room.maxShards.set(roomConfig.maxShards);
|
|
172
203
|
}
|
|
204
|
+
|
|
205
|
+
await this.ensureMinShards(roomId);
|
|
173
206
|
}
|
|
174
207
|
|
|
175
208
|
@Request({
|
|
@@ -178,7 +211,14 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
178
211
|
})
|
|
179
212
|
@Guard([guardManageWorld])
|
|
180
213
|
async updateShardStats(req: Party.Request, res: ServerResponse) {
|
|
181
|
-
const
|
|
214
|
+
const parseResult = UpdateShardStatsSchema.safeParse(await req.json());
|
|
215
|
+
if (!parseResult.success) {
|
|
216
|
+
return res.badRequest("Invalid shard stats", {
|
|
217
|
+
details: parseResult.error
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const body = parseResult.data;
|
|
182
222
|
const { shardId, connections, status } = body;
|
|
183
223
|
const shard = this.shards()[shardId];
|
|
184
224
|
|
|
@@ -186,11 +226,19 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
186
226
|
return res.notFound(`Shard ${shardId} not found`);
|
|
187
227
|
}
|
|
188
228
|
|
|
229
|
+
if (body.worldId && body.worldId !== this.getWorldId()) {
|
|
230
|
+
return res.badRequest(`Shard ${shardId} belongs to world ${body.worldId}, not ${this.getWorldId()}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
189
233
|
shard.currentConnections.set(connections);
|
|
190
234
|
if (status) {
|
|
191
235
|
shard.status.set(status);
|
|
192
236
|
}
|
|
193
237
|
shard.lastHeartbeat.set(Date.now());
|
|
238
|
+
|
|
239
|
+
if (this.shouldCompleteDrain(shard)) {
|
|
240
|
+
this.removeShard(shard.id);
|
|
241
|
+
}
|
|
194
242
|
}
|
|
195
243
|
|
|
196
244
|
@Request({
|
|
@@ -199,7 +247,14 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
199
247
|
})
|
|
200
248
|
@Guard([guardManageWorld])
|
|
201
249
|
async scaleRoom(req: Party.Request, res: ServerResponse) {
|
|
202
|
-
const
|
|
250
|
+
const parseResult = ScaleRoomSchema.safeParse(await req.json());
|
|
251
|
+
if (!parseResult.success) {
|
|
252
|
+
return res.badRequest("Invalid scale room request", {
|
|
253
|
+
details: parseResult.error
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const data = parseResult.data;
|
|
203
258
|
const { targetShardCount, shardTemplate, roomId } = data;
|
|
204
259
|
|
|
205
260
|
// Validate room exists
|
|
@@ -223,8 +278,8 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
223
278
|
|
|
224
279
|
// Handle scaling down
|
|
225
280
|
if (targetShardCount < previousShardCount) {
|
|
226
|
-
// Find candidates
|
|
227
|
-
const
|
|
281
|
+
// Find drain candidates (prioritize already-draining or low-connection shards)
|
|
282
|
+
const shardsToDrain = [...roomShards]
|
|
228
283
|
.sort((a, b) => {
|
|
229
284
|
// Prioritize draining status
|
|
230
285
|
if (a.status() === 'draining' && b.status() !== 'draining') return -1;
|
|
@@ -235,14 +290,12 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
235
290
|
})
|
|
236
291
|
.slice(0, previousShardCount - targetShardCount);
|
|
237
292
|
|
|
238
|
-
//
|
|
239
|
-
const
|
|
240
|
-
shard
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
for (const shard of shardsToRemove) {
|
|
245
|
-
delete this.shards()[shard.id];
|
|
293
|
+
// Complete empty drains immediately, otherwise stop routing new clients there.
|
|
294
|
+
for (const shard of shardsToDrain) {
|
|
295
|
+
shard.status.set('draining');
|
|
296
|
+
if (this.shouldCompleteDrain(shard)) {
|
|
297
|
+
this.removeShard(shard.id);
|
|
298
|
+
}
|
|
246
299
|
}
|
|
247
300
|
|
|
248
301
|
return;
|
|
@@ -368,11 +421,24 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
368
421
|
}
|
|
369
422
|
}
|
|
370
423
|
|
|
371
|
-
// Get active shards
|
|
372
|
-
|
|
373
|
-
.filter(shard => shard && shard.status() === 'active');
|
|
424
|
+
// Get active shards with available capacity
|
|
425
|
+
let activeShards = this.getAvailableShards(roomShards);
|
|
374
426
|
|
|
375
427
|
if (activeShards.length === 0) {
|
|
428
|
+
if (autoCreate && this.canCreateShard(room, roomShards.length)) {
|
|
429
|
+
const newShard = await this.createShard(roomId);
|
|
430
|
+
if (newShard) {
|
|
431
|
+
return {
|
|
432
|
+
shardId: newShard.id,
|
|
433
|
+
url: newShard.url()
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (roomShards.some(shard => shard.status() === 'active')) {
|
|
439
|
+
return { error: `No shard capacity available for room ${roomId}` };
|
|
440
|
+
}
|
|
441
|
+
|
|
376
442
|
return { error: `No active shards available for room ${roomId}` };
|
|
377
443
|
}
|
|
378
444
|
|
|
@@ -412,6 +478,18 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
412
478
|
};
|
|
413
479
|
}
|
|
414
480
|
|
|
481
|
+
private getAvailableShards(shards: ShardInfo[]) {
|
|
482
|
+
return shards.filter(shard =>
|
|
483
|
+
shard
|
|
484
|
+
&& shard.status() === 'active'
|
|
485
|
+
&& shard.currentConnections() < shard.maxConnections()
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private canCreateShard(room: RoomConfig, currentShardCount: number) {
|
|
490
|
+
return room.maxShards() === undefined || currentShardCount < room.maxShards()!;
|
|
491
|
+
}
|
|
492
|
+
|
|
415
493
|
// Private methods
|
|
416
494
|
private async createShard(
|
|
417
495
|
roomId: string,
|
|
@@ -425,7 +503,8 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
425
503
|
}
|
|
426
504
|
|
|
427
505
|
// Generate shard ID
|
|
428
|
-
const
|
|
506
|
+
const worldId = this.getWorldId();
|
|
507
|
+
const shardId = `${roomId}:${worldId}:${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
429
508
|
|
|
430
509
|
// Generate URL from template
|
|
431
510
|
const template = urlTemplate || this.defaultShardUrlTemplate();
|
|
@@ -438,6 +517,7 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
438
517
|
const newShard = new ShardInfo();
|
|
439
518
|
newShard.id = shardId;
|
|
440
519
|
newShard.roomId.set(roomId);
|
|
520
|
+
newShard.worldId.set(worldId);
|
|
441
521
|
newShard.url.set(url);
|
|
442
522
|
newShard.maxConnections.set(max);
|
|
443
523
|
newShard.currentConnections.set(0);
|
|
@@ -448,4 +528,24 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
448
528
|
this.shards()[shardId] = newShard;
|
|
449
529
|
return newShard;
|
|
450
530
|
}
|
|
531
|
+
|
|
532
|
+
private async ensureMinShards(roomId: string) {
|
|
533
|
+
const room = this.rooms()[roomId];
|
|
534
|
+
if (!room) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const currentShardCount = Object.values(this.shards())
|
|
539
|
+
.filter(shard => shard.roomId() === roomId)
|
|
540
|
+
.length;
|
|
541
|
+
const targetShardCount = room.minShards();
|
|
542
|
+
|
|
543
|
+
if (currentShardCount >= targetShardCount) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (let i = currentShardCount; i < targetShardCount; i++) {
|
|
548
|
+
await this.createShard(roomId);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
451
551
|
}
|