@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/chunk-EUXUH3YW.js +15 -0
  3. package/dist/chunk-EUXUH3YW.js.map +1 -0
  4. package/dist/cloudflare/index.d.ts +71 -0
  5. package/dist/cloudflare/index.js +320 -0
  6. package/dist/cloudflare/index.js.map +1 -0
  7. package/dist/index.d.ts +66 -187
  8. package/dist/index.js +727 -106
  9. package/dist/index.js.map +1 -1
  10. package/dist/node/index.d.ts +164 -0
  11. package/dist/node/index.js +786 -0
  12. package/dist/node/index.js.map +1 -0
  13. package/dist/party-dNs-hqkq.d.ts +175 -0
  14. package/examples/cloudflare/README.md +62 -0
  15. package/examples/cloudflare/node_modules/.bin/tsc +17 -0
  16. package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
  17. package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
  18. package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
  19. package/examples/cloudflare/package.json +24 -0
  20. package/examples/cloudflare/public/index.html +443 -0
  21. package/examples/cloudflare/src/index.ts +28 -0
  22. package/examples/cloudflare/src/room.ts +44 -0
  23. package/examples/cloudflare/tsconfig.json +10 -0
  24. package/examples/cloudflare/wrangler.jsonc +25 -0
  25. package/examples/node/README.md +57 -0
  26. package/examples/node/node_modules/.bin/tsc +17 -0
  27. package/examples/node/node_modules/.bin/tsserver +17 -0
  28. package/examples/node/node_modules/.bin/tsx +17 -0
  29. package/examples/node/package.json +23 -0
  30. package/examples/node/public/index.html +443 -0
  31. package/examples/node/room.ts +44 -0
  32. package/examples/node/server.sqlite.ts +52 -0
  33. package/examples/node/server.ts +51 -0
  34. package/examples/node/tsconfig.json +10 -0
  35. package/examples/node-game/README.md +66 -0
  36. package/examples/node-game/package.json +23 -0
  37. package/examples/node-game/public/index.html +705 -0
  38. package/examples/node-game/room.ts +145 -0
  39. package/examples/node-game/server.sqlite.ts +54 -0
  40. package/examples/node-game/server.ts +53 -0
  41. package/examples/node-game/tsconfig.json +10 -0
  42. package/examples/node-shard/README.md +32 -0
  43. package/examples/node-shard/dev.ts +39 -0
  44. package/examples/node-shard/package.json +24 -0
  45. package/examples/node-shard/public/index.html +777 -0
  46. package/examples/node-shard/room-server.ts +68 -0
  47. package/examples/node-shard/room.ts +105 -0
  48. package/examples/node-shard/shared.ts +6 -0
  49. package/examples/node-shard/tsconfig.json +14 -0
  50. package/examples/node-shard/world-server.ts +169 -0
  51. package/package.json +14 -5
  52. package/readme.md +371 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/jwt.ts +1 -5
  55. package/src/mock.ts +29 -7
  56. package/src/node/index.ts +1112 -0
  57. package/src/server.ts +600 -51
  58. package/src/session.guard.ts +6 -2
  59. package/src/shard.ts +91 -23
  60. package/src/storage.ts +29 -5
  61. package/src/testing.ts +4 -3
  62. package/src/types/party.ts +4 -1
  63. package/src/world.guard.ts +23 -4
  64. package/src/world.ts +121 -21
  65. package/examples/game/.vscode/launch.json +0 -11
  66. package/examples/game/.vscode/settings.json +0 -11
  67. package/examples/game/README.md +0 -40
  68. package/examples/game/app/client.tsx +0 -15
  69. package/examples/game/app/components/Admin.tsx +0 -1089
  70. package/examples/game/app/components/Room.tsx +0 -162
  71. package/examples/game/app/styles.css +0 -31
  72. package/examples/game/package-lock.json +0 -225
  73. package/examples/game/package.json +0 -20
  74. package/examples/game/party/game.room.ts +0 -32
  75. package/examples/game/party/server.ts +0 -10
  76. package/examples/game/party/shard.ts +0 -5
  77. package/examples/game/partykit.json +0 -14
  78. package/examples/game/public/favicon.ico +0 -0
  79. package/examples/game/public/index.html +0 -27
  80. package/examples/game/public/normalize.css +0 -351
  81. package/examples/game/shared/room.schema.ts +0 -14
  82. package/examples/game/tsconfig.json +0 -109
@@ -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.id}`);
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.id}`);
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>(); // Map privateId -> 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 = 'default';
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.split(':')[0];
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 clientConn = this.connectionMap.get(message.targetClientId);
51
- if (clientConn) {
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.send(message.data);
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.worldUrl) {
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.set(conn.id, conn);
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: conn.id,
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.id,
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.delete(conn.id);
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: conn.id,
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.size;
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 worldRoom = this.room.context.parties.world.get('world-default');
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
- this.memory.set(key, value);
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
- this.memory.delete(key);
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
- return this.memory;
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
+ }
@@ -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;
@@ -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.headers.get("Authorization") ?? url.searchParams.get("world-auth-token");
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
- // TODO
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
- setTimeout(() => this.cleanupInactiveShards(), 60000);
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 roomConfig: z.infer<typeof RoomConfigSchema> = await req.json();
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 body: { shardId: string; connections: number; status: ShardStatus } = await req.json();
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 data: z.infer<typeof ScaleRoomSchema> = await req.json();
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 for removal (prioritize draining or low-connection shards)
227
- const shardsToRemove = [...roomShards]
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
- // Remove the selected shards
239
- const shardsToKeep = roomShards.filter(
240
- shard => !shardsToRemove.some(s => s.id === shard.id)
241
- );
242
-
243
- // Update shards
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
- const activeShards = roomShards
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 shardId = `${roomId}:${Date.now()}-${Math.floor(Math.random() * 10000)}`;
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
  }
@@ -1,11 +0,0 @@
1
- {
2
- "configurations": [
3
- {
4
- "type": "node",
5
- "request": "attach",
6
- "name": "PartyKit debugger",
7
- "address": "localhost",
8
- "port": 9229
9
- }
10
- ]
11
- }