@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
@@ -0,0 +1,474 @@
1
+ import type {
2
+ DurableObjectNamespace,
3
+ DurableObjectState,
4
+ WebSocket as CloudflareWebSocket,
5
+ } from "@cloudflare/workers-types";
6
+ import type * as Party from "../types/party";
7
+
8
+ export type CloudflareRoomServerConstructor<TServer extends Party.Server = Party.Server> = {
9
+ new (room: Party.Room): TServer;
10
+ };
11
+
12
+ export type CloudflareRoomWorkerOptions = {
13
+ binding: string;
14
+ partiesPath?: string;
15
+ env?: Record<string, unknown>;
16
+ rooms?: Record<string, CloudflareRoomServerConstructor>;
17
+ };
18
+
19
+ export type CloudflareRoomEnv = Record<string, unknown>;
20
+
21
+ type ParsedPartyPath = {
22
+ namespace: string;
23
+ roomId: string;
24
+ };
25
+
26
+ type CloudflareRoomRecord = {
27
+ room: CloudflareRoom;
28
+ server: Party.Server;
29
+ started: Promise<void>;
30
+ };
31
+
32
+ type CloudflareRuntimeConfig = Required<Pick<CloudflareRoomWorkerOptions, "binding" | "partiesPath">> & {
33
+ ServerClass: CloudflareRoomServerConstructor;
34
+ env: Record<string, unknown>;
35
+ rooms: Record<string, CloudflareRoomServerConstructor>;
36
+ };
37
+
38
+ const DEFAULT_PARTIES_PATH = "/parties/main";
39
+ const WEBSOCKET_OPEN = 1;
40
+
41
+ let runtimeConfig: CloudflareRuntimeConfig | undefined;
42
+
43
+ export function createCloudflareRoomWorker<TServer extends Party.Server>(
44
+ ServerClass: CloudflareRoomServerConstructor<TServer>,
45
+ options: CloudflareRoomWorkerOptions
46
+ ) {
47
+ runtimeConfig = createRuntimeConfig(ServerClass, options);
48
+
49
+ return {
50
+ async fetch(
51
+ request: Request,
52
+ env: CloudflareRoomEnv,
53
+ ctx: unknown
54
+ ): Promise<Response> {
55
+ return dispatchCloudflareRoomRequest(request, env, ctx);
56
+ },
57
+ };
58
+ }
59
+
60
+ export async function dispatchCloudflareRoomRequest(
61
+ request: Request,
62
+ env: CloudflareRoomEnv,
63
+ _ctx?: unknown
64
+ ): Promise<Response> {
65
+ const config = getRuntimeConfig();
66
+ const parsed = parsePartyRequest(request.url, config.partiesPath);
67
+
68
+ if (!parsed) {
69
+ return new Response("Not Found", { status: 404 });
70
+ }
71
+
72
+ const namespace = getNamespace(env, config.binding);
73
+ const stub = namespace.get(namespace.idFromName(parsed.roomId));
74
+ return fetchDurableObjectStub(stub, request);
75
+ }
76
+
77
+ export class SigneRoomDurableObject {
78
+ private recordPromise?: Promise<CloudflareRoomRecord>;
79
+
80
+ constructor(
81
+ private readonly ctx: DurableObjectState,
82
+ private readonly env: CloudflareRoomEnv
83
+ ) {}
84
+
85
+ async fetch(request: Request): Promise<Response> {
86
+ const config = getRuntimeConfig();
87
+ const parsed = parsePartyRequest(request.url, config.partiesPath);
88
+
89
+ if (!parsed) {
90
+ return new Response("Not Found", { status: 404 });
91
+ }
92
+
93
+ if (isWebSocketUpgrade(request)) {
94
+ return this.acceptWebSocket(request, parsed);
95
+ }
96
+
97
+ const record = await this.getRecord(parsed);
98
+ return record.server.onRequest?.(request as unknown as Party.Request)
99
+ ?? new Response("Not Found", { status: 404 });
100
+ }
101
+
102
+ async alarm(): Promise<void> {
103
+ const record = await this.recordPromise;
104
+ await record?.server.onAlarm?.();
105
+ }
106
+
107
+ private async acceptWebSocket(
108
+ request: Request,
109
+ parsed: ParsedPartyPath
110
+ ): Promise<Response> {
111
+ const pair = new WebSocketPair();
112
+ const [client, server] = Object.values(pair) as [
113
+ CloudflareWebSocket,
114
+ CloudflareWebSocket,
115
+ ];
116
+ const record = await this.getRecord(parsed);
117
+ const connection = new CloudflareConnection(
118
+ server,
119
+ request.url,
120
+ getConnectionIdFromUrl(request.url)
121
+ );
122
+
123
+ server.accept();
124
+
125
+ await record.server.onConnect?.(connection as unknown as Party.Connection, {
126
+ request: request as unknown as Party.Request,
127
+ });
128
+
129
+ server.addEventListener("message", (event) => {
130
+ void record.server.onMessage?.(
131
+ normalizeWebSocketMessage(event.data),
132
+ connection as unknown as Party.Connection
133
+ );
134
+ });
135
+ server.addEventListener("close", () => {
136
+ record.room.deleteConnection(connection.id, connection);
137
+ void record.server.onClose?.(connection as unknown as Party.Connection);
138
+ });
139
+ server.addEventListener("error", (event) => {
140
+ const errorData = (event as { error?: unknown; message?: string }).error;
141
+ const error = errorData instanceof Error
142
+ ? errorData
143
+ : new Error((event as { message?: string }).message ?? "Cloudflare WebSocket error");
144
+ void record.server.onError?.(connection as unknown as Party.Connection, error);
145
+ });
146
+ record.room.addConnection(connection);
147
+
148
+ return new Response(null, {
149
+ status: 101,
150
+ webSocket: client,
151
+ } as ResponseInit & { webSocket: CloudflareWebSocket });
152
+ }
153
+
154
+ private async getRecord(parsed: ParsedPartyPath): Promise<CloudflareRoomRecord> {
155
+ if (!this.recordPromise) {
156
+ this.recordPromise = this.createRecord(parsed);
157
+ }
158
+
159
+ return this.recordPromise;
160
+ }
161
+
162
+ private async createRecord(parsed: ParsedPartyPath): Promise<CloudflareRoomRecord> {
163
+ const config = getRuntimeConfig();
164
+ const ServerClass = config.rooms[parsed.namespace] ?? config.ServerClass;
165
+ const room = new CloudflareRoom({
166
+ id: parsed.roomId,
167
+ name: parsed.namespace,
168
+ env: {
169
+ ...config.env,
170
+ ...this.env,
171
+ },
172
+ state: this.ctx,
173
+ binding: config.binding,
174
+ partiesPath: config.partiesPath,
175
+ });
176
+ const server = new ServerClass(room as Party.Room);
177
+ const record: CloudflareRoomRecord = {
178
+ room,
179
+ server,
180
+ started: Promise.resolve(server.onStart?.()).then(() => undefined),
181
+ };
182
+
183
+ await record.started;
184
+ return record;
185
+ }
186
+ }
187
+
188
+ export class CloudflareRoom implements Party.Room {
189
+ readonly id: string;
190
+ readonly internalID: string;
191
+ readonly name: string;
192
+ readonly env: Record<string, unknown>;
193
+ readonly storage: Party.Storage;
194
+ readonly context: Party.Context;
195
+ readonly connections = new Map<string, Party.Connection>();
196
+ readonly parties: Party.Context["parties"];
197
+ readonly analytics = {} as Party.Room["analytics"];
198
+
199
+ constructor(options: {
200
+ id: string;
201
+ name: string;
202
+ env: Record<string, unknown>;
203
+ state: DurableObjectState;
204
+ binding: string;
205
+ partiesPath: string;
206
+ }) {
207
+ this.id = options.id;
208
+ this.internalID = `${options.name}:${options.id}`;
209
+ this.name = options.name;
210
+ this.env = options.env;
211
+ this.storage = options.state.storage as Party.Storage;
212
+ this.parties = createPartiesContext(options.env, options.binding, options.partiesPath);
213
+ this.context = {
214
+ parties: this.parties,
215
+ ai: {},
216
+ vectorize: {},
217
+ analytics: this.analytics,
218
+ assets: {
219
+ fetch: async () => null,
220
+ },
221
+ bindings: {
222
+ r2: {},
223
+ kv: {},
224
+ },
225
+ } as Party.Context;
226
+ this.blockConcurrencyWhile = options.state.blockConcurrencyWhile.bind(options.state);
227
+ }
228
+
229
+ blockConcurrencyWhile: Party.Room["blockConcurrencyWhile"];
230
+
231
+ broadcast(msg: string | ArrayBuffer | ArrayBufferView, without: string[] = []) {
232
+ for (const connection of this.connections.values()) {
233
+ if (!without.includes(connection.id)) {
234
+ connection.send(msg);
235
+ }
236
+ }
237
+ }
238
+
239
+ getConnection<TState = unknown>(id: string): Party.Connection<TState> | undefined {
240
+ let connection: Party.Connection | undefined;
241
+ for (const current of this.connections.values()) {
242
+ if (current.id === id || current.sessionId === id) {
243
+ connection = current;
244
+ }
245
+ }
246
+ return connection as Party.Connection<TState> | undefined;
247
+ }
248
+
249
+ getConnections<TState = unknown>(): Iterable<Party.Connection<TState>> {
250
+ return Array.from(this.connections.values()) as Party.Connection<TState>[];
251
+ }
252
+
253
+ addConnection(connection: CloudflareConnection) {
254
+ this.connections.set(connection.id, connection as unknown as Party.Connection);
255
+ }
256
+
257
+ deleteConnection(id: string, connection?: CloudflareConnection) {
258
+ if (connection) {
259
+ this.connections.delete(connection.id);
260
+ return;
261
+ }
262
+
263
+ for (const [connectionKey, current] of this.connections) {
264
+ if (current.id === id || current.sessionId === id) {
265
+ this.connections.delete(connectionKey);
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ export class CloudflareConnection<TState = unknown> {
272
+ readonly id = createConnectionId();
273
+ readonly sessionId: string;
274
+ readonly socket: this = this;
275
+ readonly uri: string;
276
+ state: Party.ConnectionState<TState> | TState | null = null;
277
+ private attachment: unknown = null;
278
+
279
+ constructor(
280
+ private readonly webSocket: CloudflareWebSocket,
281
+ uri: string,
282
+ sessionId?: string
283
+ ) {
284
+ this.sessionId = sessionId || this.id;
285
+ this.uri = uri;
286
+ }
287
+
288
+ send(data: string | ArrayBuffer | ArrayBufferView) {
289
+ if (
290
+ this.webSocket.readyState === undefined ||
291
+ this.webSocket.readyState === WEBSOCKET_OPEN
292
+ ) {
293
+ this.webSocket.send(data);
294
+ }
295
+ }
296
+
297
+ close(code?: number, reason?: string) {
298
+ this.webSocket.close(code, reason);
299
+ }
300
+
301
+ setState(state: TState | Party.ConnectionSetStateFn<TState> | null) {
302
+ this.state = typeof state === "function"
303
+ ? (state as Party.ConnectionSetStateFn<TState>)(this.state as Party.ConnectionState<TState>)
304
+ : state;
305
+ return this.state as Party.ConnectionState<TState>;
306
+ }
307
+
308
+ serializeAttachment<T = unknown>(attachment: T): void {
309
+ this.attachment = attachment;
310
+ }
311
+
312
+ deserializeAttachment<T = unknown>(): T | null {
313
+ return this.attachment as T | null;
314
+ }
315
+ }
316
+
317
+ function createRuntimeConfig<TServer extends Party.Server>(
318
+ ServerClass: CloudflareRoomServerConstructor<TServer>,
319
+ options: CloudflareRoomWorkerOptions
320
+ ): CloudflareRuntimeConfig {
321
+ return {
322
+ ServerClass: ServerClass as CloudflareRoomServerConstructor,
323
+ binding: options.binding,
324
+ partiesPath: normalizePath(options.partiesPath ?? DEFAULT_PARTIES_PATH),
325
+ env: options.env ?? {},
326
+ rooms: {
327
+ main: ServerClass as CloudflareRoomServerConstructor,
328
+ ...(options.rooms ?? {}),
329
+ },
330
+ };
331
+ }
332
+
333
+ function getRuntimeConfig(): CloudflareRuntimeConfig {
334
+ if (!runtimeConfig) {
335
+ throw new Error(
336
+ "createCloudflareRoomWorker() must be called before using SigneRoomDurableObject."
337
+ );
338
+ }
339
+
340
+ return runtimeConfig;
341
+ }
342
+
343
+ function createPartiesContext(
344
+ env: Record<string, unknown>,
345
+ binding: string,
346
+ partiesPath: string
347
+ ): Party.Context["parties"] {
348
+ return new Proxy({}, {
349
+ get(_target, namespace: string) {
350
+ return {
351
+ get(roomId: string) {
352
+ return {
353
+ connect: () => {
354
+ throw new Error("Party stub connect() is not implemented by @signe/room/cloudflare");
355
+ },
356
+ socket: async () => {
357
+ throw new Error("Party stub socket() is not implemented by @signe/room/cloudflare");
358
+ },
359
+ fetch(pathOrInit?: string | RequestInit | Request, init?: RequestInit) {
360
+ const namespaceBinding = getNamespace(env, binding);
361
+ const stub = namespaceBinding.get(namespaceBinding.idFromName(roomId));
362
+ if (pathOrInit instanceof Request) {
363
+ return fetchDurableObjectStub(stub, pathOrInit);
364
+ }
365
+ const path = typeof pathOrInit === "string" ? pathOrInit : "/";
366
+ const requestInit = typeof pathOrInit === "string" ? init : pathOrInit;
367
+ return fetchDurableObjectStub(
368
+ stub,
369
+ toLocalUrl(`${getNamespacePath(partiesPath, namespace, roomId)}${normalizeStubPath(path)}`),
370
+ requestInit as RequestInit | undefined
371
+ );
372
+ },
373
+ };
374
+ },
375
+ };
376
+ },
377
+ }) as Party.Context["parties"];
378
+ }
379
+
380
+ function getNamespace(env: Record<string, unknown>, binding: string) {
381
+ const namespace = env[binding] as DurableObjectNamespace | undefined;
382
+
383
+ if (!namespace) {
384
+ throw new Error(`Missing Durable Object binding: ${binding}`);
385
+ }
386
+
387
+ return namespace;
388
+ }
389
+
390
+ function fetchDurableObjectStub(
391
+ stub: unknown,
392
+ input: Request | string | URL,
393
+ init?: RequestInit
394
+ ): Promise<Response> {
395
+ return (stub as {
396
+ fetch(request: Request | string | URL, init?: RequestInit): Promise<Response>;
397
+ }).fetch(input, init);
398
+ }
399
+
400
+ function parsePartyRequest(url: string, partiesPath: string): ParsedPartyPath | null {
401
+ const requestUrl = new URL(url);
402
+ const segments = trimSlashes(requestUrl.pathname).split("/");
403
+ const configuredSegments = trimSlashes(partiesPath).split("/");
404
+ const baseSegments = configuredSegments.slice(0, -1);
405
+
406
+ if (segments.length < baseSegments.length + 2) {
407
+ return null;
408
+ }
409
+
410
+ for (let index = 0; index < baseSegments.length; index++) {
411
+ if (segments[index] !== baseSegments[index]) {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ return {
417
+ namespace: decodeURIComponent(segments[baseSegments.length]),
418
+ roomId: decodeURIComponent(segments[baseSegments.length + 1]),
419
+ };
420
+ }
421
+
422
+ function getNamespacePath(partiesPath: string, namespace: string, roomId: string) {
423
+ const baseSegments = trimSlashes(partiesPath).split("/").slice(0, -1);
424
+ return `/${[...baseSegments, namespace, encodeURIComponent(roomId)].join("/")}`;
425
+ }
426
+
427
+ function isWebSocketUpgrade(request: Request) {
428
+ return request.headers.get("Upgrade")?.toLowerCase() === "websocket";
429
+ }
430
+
431
+ function normalizeWebSocketMessage(data: unknown): string | ArrayBuffer | ArrayBufferView {
432
+ if (typeof data === "string" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
433
+ return data;
434
+ }
435
+
436
+ return String(data);
437
+ }
438
+
439
+ function normalizePath(path: string) {
440
+ return `/${trimSlashes(path)}`;
441
+ }
442
+
443
+ function toLocalUrl(path: string) {
444
+ return path.startsWith("http://") || path.startsWith("https://")
445
+ ? path
446
+ : `http://localhost${path.startsWith("/") ? path : `/${path}`}`;
447
+ }
448
+
449
+ function normalizeStubPath(path: string) {
450
+ if (!path || path === "/") {
451
+ return "";
452
+ }
453
+ return path.startsWith("/") ? path : `/${path}`;
454
+ }
455
+
456
+ function getConnectionIdFromUrl(url: string) {
457
+ const requestedId = new URL(url).searchParams.get("id")?.trim();
458
+ return requestedId || undefined;
459
+ }
460
+
461
+ function trimSlashes(value: string) {
462
+ return value.replace(/^\/+|\/+$/g, "");
463
+ }
464
+
465
+ function createConnectionId() {
466
+ return Math.random().toString(36).slice(2, 12);
467
+ }
468
+
469
+ declare const WebSocketPair: {
470
+ new (): {
471
+ 0: CloudflareWebSocket;
472
+ 1: CloudflareWebSocket;
473
+ };
474
+ };
package/src/jwt.ts CHANGED
@@ -132,9 +132,7 @@ export class JWTAuth {
132
132
  };
133
133
 
134
134
  // Encode header and payload
135
- // @ts-expect-error - TS doesn't have a built-in TextEncoder
136
135
  const encodedHeader: string = this.base64UrlEncode(this.encoder.encode(JSON.stringify(header)));
137
- // @ts-expect-error - TS doesn't have a built-in TextEncoder
138
136
  const encodedPayload: string = this.base64UrlEncode(this.encoder.encode(JSON.stringify(fullPayload)));
139
137
 
140
138
  // Create signature base
@@ -174,9 +172,7 @@ export class JWTAuth {
174
172
 
175
173
  // Decode header and payload
176
174
  try {
177
- // @ts-expect-error - TS doesn't have a built-in TextDecoder
178
175
  const header: JWTHeader = JSON.parse(this.decoder.decode(this.base64UrlDecode(encodedHeader)));
179
- // @ts-expect-error - TS doesn't have a built-in TextDecoder
180
176
  const payload: JWTPayload = JSON.parse(this.decoder.decode(this.base64UrlDecode(encodedPayload)));
181
177
 
182
178
  // Check algorithm
@@ -214,4 +210,4 @@ export class JWTAuth {
214
210
  throw new Error('Token verification failed: Unknown error');
215
211
  }
216
212
  }
217
- }
213
+ }
package/src/mock.ts CHANGED
@@ -8,9 +8,9 @@ export class MockPartyClient {
8
8
  id : string
9
9
  conn: MockConnection;
10
10
 
11
- constructor(public server: Server, id?: string) {
12
- this.id = id || generateShortUUID()
13
- this.conn = new MockConnection(this)
11
+ constructor(public server: Server, sessionId?: string) {
12
+ this.id = generateShortUUID()
13
+ this.conn = new MockConnection(this, sessionId || this.id)
14
14
  }
15
15
 
16
16
  addEventListener(event, cb) {
@@ -49,8 +49,8 @@ export class MockPartyClient {
49
49
  class MockLobby {
50
50
  constructor(public server: Server, public lobbyId: string) {}
51
51
 
52
- socket(_init?: any) {
53
- return new MockPartyClient(this.server)
52
+ socket(init?: { id?: string }) {
53
+ return new MockPartyClient(this.server, init?.id)
54
54
  }
55
55
 
56
56
  async connection(idOrOptions?: string | { id?: string, query?: Record<string, string>, headers?: Record<string, string> }, maybeOptions?: { query?: Record<string, string>, headers?: Record<string, string> }) {
@@ -142,7 +142,13 @@ class MockPartyRoom {
142
142
  }
143
143
 
144
144
  getConnection(id: string) {
145
- return this.clients.get(id)
145
+ let connection: MockConnection | undefined;
146
+ for (const client of this.clients.values()) {
147
+ if (client.conn.id === id || client.conn.sessionId === id) {
148
+ connection = client.conn;
149
+ }
150
+ }
151
+ return connection;
146
152
  }
147
153
 
148
154
  getConnections() {
@@ -152,15 +158,30 @@ class MockPartyRoom {
152
158
  clear() {
153
159
  this.clients.clear();
154
160
  }
161
+
162
+ deleteConnection(id: string, connection?: MockConnection) {
163
+ if (connection) {
164
+ this.clients.delete(connection.id);
165
+ return;
166
+ }
167
+
168
+ for (const [connectionKey, client] of this.clients) {
169
+ if (client.conn.id === id || client.conn.sessionId === id) {
170
+ this.clients.delete(connectionKey);
171
+ }
172
+ }
173
+ }
155
174
  }
156
175
 
157
176
  export class MockConnection {
158
177
  server: Server;
159
178
  id: string;
179
+ sessionId: string;
160
180
 
161
- constructor(public client: MockPartyClient) {
181
+ constructor(public client: MockPartyClient, sessionId: string) {
162
182
  this.server = client.server
163
183
  this.id = client.id
184
+ this.sessionId = sessionId
164
185
  }
165
186
 
166
187
  state: any = {};
@@ -174,6 +195,7 @@ export class MockConnection {
174
195
  }
175
196
 
176
197
  close() {
198
+ (this.server.room as any).deleteConnection?.(this.id, this);
177
199
  this.server.onClose(this as any)
178
200
  }
179
201
  }