@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
@@ -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;
@@ -250,6 +253,32 @@ import type {
250
253
  * @param conn The connection object
251
254
  */
252
255
  onLeave?(user: TUser, conn: Connection): void | Promise<void>;
256
+
257
+ /**
258
+ * Called before persisted room state is loaded into the room instance.
259
+ * Return a replacement snapshot to hydrate serialized values before load.
260
+ */
261
+ onStorageRestore?(context: {
262
+ snapshot: any;
263
+ room: Room;
264
+ server: Server;
265
+ legacy: boolean;
266
+ }): any | Promise<any>;
267
+
268
+ /**
269
+ * Called for each restored entry in the room's @users() collection before
270
+ * persisted room state is loaded. Return a replacement user snapshot to
271
+ * hydrate serialized nested values before load.
272
+ */
273
+ onUserStorageRestore?(context: {
274
+ userSnapshot: any;
275
+ user: TUser | undefined;
276
+ publicId: string;
277
+ usersPropName: string;
278
+ room: Room;
279
+ server: Server;
280
+ legacy: boolean;
281
+ }): any | Promise<any>;
253
282
  }
254
283
 
255
284
  /** @deprecated Use `Party.Room` instead */
@@ -548,4 +577,4 @@ import type {
548
577
  export type PartyKitConnection = Connection;
549
578
 
550
579
  /** @deprecated Use `Party.ServerOptions` instead */
551
- export type PartyServerOptions = ServerOptions;
580
+ 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
+ }