@signe/room 2.10.0 → 3.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.
Files changed (84) 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 +87 -188
  8. package/dist/index.js +860 -114
  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 +418 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/index.ts +2 -2
  55. package/src/jwt.ts +1 -5
  56. package/src/mock.ts +29 -7
  57. package/src/node/index.ts +1112 -0
  58. package/src/server.ts +781 -60
  59. package/src/session.guard.ts +6 -2
  60. package/src/shard.ts +91 -23
  61. package/src/storage.ts +29 -5
  62. package/src/testing.ts +4 -3
  63. package/src/types/party.ts +30 -1
  64. package/src/world.guard.ts +23 -4
  65. package/src/world.ts +121 -21
  66. package/tests/storage-restore.spec.ts +122 -0
  67. package/examples/game/.vscode/launch.json +0 -11
  68. package/examples/game/.vscode/settings.json +0 -11
  69. package/examples/game/README.md +0 -40
  70. package/examples/game/app/client.tsx +0 -15
  71. package/examples/game/app/components/Admin.tsx +0 -1089
  72. package/examples/game/app/components/Room.tsx +0 -162
  73. package/examples/game/app/styles.css +0 -31
  74. package/examples/game/package-lock.json +0 -225
  75. package/examples/game/package.json +0 -20
  76. package/examples/game/party/game.room.ts +0 -32
  77. package/examples/game/party/server.ts +0 -10
  78. package/examples/game/party/shard.ts +0 -5
  79. package/examples/game/partykit.json +0 -14
  80. package/examples/game/public/favicon.ico +0 -0
  81. package/examples/game/public/index.html +0 -27
  82. package/examples/game/public/normalize.css +0 -351
  83. package/examples/game/shared/room.schema.ts +0 -14
  84. package/examples/game/tsconfig.json +0 -109
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
  }
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { signal } from "@signe/reactive";
3
+ import { sync, users } from "@signe/sync";
4
+ import { Room, Server, ServerIo } from "../src";
5
+
6
+ class Item {
7
+ id = signal("");
8
+ }
9
+
10
+ class Player {
11
+ items = signal<any[]>([]);
12
+ }
13
+
14
+ describe("storage restore hooks", () => {
15
+ it("allows a room to hydrate the persisted root snapshot before load", async () => {
16
+ @Room({ path: "demo" })
17
+ class DemoRoom {
18
+ @sync()
19
+ title = signal("");
20
+
21
+ onStorageRestore({ snapshot }: { snapshot: any }) {
22
+ return {
23
+ ...snapshot,
24
+ title: `${snapshot.title}:hydrated`,
25
+ };
26
+ }
27
+ }
28
+
29
+ class DemoServer extends Server {
30
+ rooms = [DemoRoom];
31
+ }
32
+
33
+ const io = new ServerIo("demo");
34
+ await io.storage.put("state:title", "saved");
35
+
36
+ const server = new DemoServer(io as any);
37
+ await server.onStart();
38
+
39
+ expect((server.subRoom as any).title()).toBe("saved:hydrated");
40
+ });
41
+
42
+ it("allows a room to hydrate persisted user snapshots before load", async () => {
43
+ @Room({ path: "demo" })
44
+ class DemoRoom {
45
+ @users(Player)
46
+ players = signal<Record<string, Player>>({});
47
+
48
+ async onUserStorageRestore({ userSnapshot, user }: { userSnapshot: any; user?: Player }) {
49
+ return {
50
+ ...userSnapshot,
51
+ items: userSnapshot.items.map((entry: any) => {
52
+ const item = new Item();
53
+ item.id.set(entry.id);
54
+ return item;
55
+ }),
56
+ usedHelperInstance: user instanceof Player,
57
+ };
58
+ }
59
+ }
60
+
61
+ class DemoServer extends Server {
62
+ rooms = [DemoRoom];
63
+ }
64
+
65
+ const io = new ServerIo("demo");
66
+ await io.storage.put("state:players.public-1.items", [{ id: "potion" }]);
67
+
68
+ const server = new DemoServer(io as any);
69
+ await server.onStart();
70
+
71
+ const restoredPlayer = (server.subRoom as any).players()["public-1"];
72
+ expect(restoredPlayer).toBeInstanceOf(Player);
73
+ expect(restoredPlayer.items()[0]).toBeInstanceOf(Item);
74
+ expect(restoredPlayer.items()[0].id()).toBe("potion");
75
+ expect((restoredPlayer as any).usedHelperInstance).toBe(true);
76
+ });
77
+
78
+ it("compacts persisted delete markers when loading room storage", async () => {
79
+ @Room({ path: "demo" })
80
+ class DemoRoom {
81
+ @sync()
82
+ items = signal<Record<string, number>>({});
83
+ }
84
+
85
+ class DemoServer extends Server {
86
+ rooms = [DemoRoom];
87
+ }
88
+
89
+ const io = new ServerIo("demo");
90
+ await io.storage.put("state:items.a", "$delete");
91
+
92
+ const server = new DemoServer(io as any);
93
+ await server.onStart();
94
+
95
+ const storageEntries = await io.storage.list({ prefix: "state:" });
96
+ expect(storageEntries.get("state:items.a")).toBeUndefined();
97
+ expect(storageEntries.get("state:.")).toEqual({ items: {} });
98
+ });
99
+
100
+ it("compacts runtime deletes instead of leaving delete markers in storage", async () => {
101
+ @Room({ path: "demo" })
102
+ class DemoRoom {
103
+ @sync()
104
+ items = signal<Record<string, number>>({ a: 1, b: 2 });
105
+ }
106
+
107
+ class DemoServer extends Server {
108
+ rooms = [DemoRoom];
109
+ }
110
+
111
+ const io = new ServerIo("demo");
112
+ const server = new DemoServer(io as any);
113
+ await server.onStart();
114
+
115
+ delete (server.subRoom as any).items().a;
116
+ await new Promise((resolve) => setTimeout(resolve, 0));
117
+
118
+ const storageEntries = await io.storage.list({ prefix: "state:" });
119
+ expect(storageEntries.get("state:items.a")).toBeUndefined();
120
+ expect(storageEntries.get("state:.")).toEqual({ items: { b: 2 } });
121
+ });
122
+ });
@@ -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
- }
@@ -1,11 +0,0 @@
1
- {
2
- "files.associations": {
3
- "partykit.json": "jsonc"
4
- },
5
- "json.schemas": [
6
- {
7
- "fileMatch": ["partykit.json"],
8
- "url": "https://www.partykit.io/schema.json"
9
- }
10
- ]
11
- }
@@ -1,40 +0,0 @@
1
- # 🎈 game
2
-
3
- Welcome to the party, pal!
4
-
5
- This is a [Partykit](https://partykit.io) project, which lets you create real-time collaborative applications with minimal coding effort.
6
-
7
- This is the **React starter** which pairs a PartyKit server with a React client.
8
-
9
- Refer to our docs for more information: https://github.com/partykit/partykit/blob/main/README.md. For more help, reach out to us on [Discord](https://discord.gg/g5uqHQJc3z), [GitHub](https://github.com/partykit/partykit), or [Twitter](https://twitter.com/partykit_io).
10
-
11
- ## Usage
12
-
13
- You can start developing by running `npm run dev` and opening [http://localhost:1999](http://localhost:1999) in your browser. When you're ready, you can deploy your application on to the PartyKit cloud with `npm run deploy`.
14
-
15
- ## Finding your way around
16
-
17
- [`party/server.ts`](./party/server.ts) is the server-side code, which is responsible for handling WebSocket events and HTTP requests.
18
-
19
- It implements a simple counter that can be incremented by any connected client. The latest state is broadcast to all connected clients.
20
-
21
- > [!NOTE]
22
- > The full Server API is available at [Party.Server in the PartyKit docs](https://docs.partykit.io/reference/partyserver-api/)
23
-
24
- [`app/client.tsx`](./src/client.ts) is the entrypoint to client-side code.
25
-
26
- [`app/components/Counter.tsx`](./src/components/Counter.tsx) connects to the server, sends `increment` events on the WebSocket, and listens for updates.
27
-
28
- > [!NOTE]
29
- > The client-side reference can be found at [PartySocket in the PartyKit docs](https://docs.partykit.io/reference/partysocket-api/)
30
-
31
- As a client-side React app, the app could be hosted every. During development, for convenience, the server serves the client-side code as well.
32
-
33
- This is achieved with the optional `serve` property in the [`partykit.json`](./partykit.json) config file.
34
-
35
- > [!NOTE]
36
- > Learn about PartyKit config under [Configuration in the PartyKit docs](https://docs.partykit.io/reference/partykit-configuration/)
37
-
38
- ## Next Steps
39
-
40
- Learn about deploying PartyKit applications in the [Deployment guide of the PartyKit docs](https://docs.partykit.io/guides/deploying-your-partykit-server/).
@@ -1,15 +0,0 @@
1
- import { createRoot } from "react-dom/client";
2
- import Admin from "./components/Admin";
3
- import "./styles.css";
4
- import Room from "./components/Room";
5
-
6
-
7
- function App() {
8
- return (
9
- <main>
10
- <Room />
11
- </main>
12
- );
13
- }
14
-
15
- createRoot(document.getElementById("app")!).render(<App />);