@signe/room 1.2.0 → 1.3.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.
@@ -3,10 +3,7 @@ import type * as Party from "../../../src/types/party";
3
3
  import { GameRoom } from "./game.room";
4
4
 
5
5
  export default class MainServer extends Server {
6
- options: Party.ServerOptions = {
7
- hibernate: true
8
- }
9
6
  rooms = [
10
- GameRoom
7
+ GameRoom
11
8
  ]
12
9
  }
@@ -1,6 +1,14 @@
1
+ import { id, users } from '../../../../sync/src/decorators';
1
2
  import { signal } from '../../../../reactive';
2
3
  import { sync } from '../../../../sync';
3
4
 
5
+ class User {
6
+ @id() id = signal('')
7
+ @sync() name = signal('')
8
+ @sync() score = signal(0)
9
+ }
10
+
4
11
  export class RoomSchema {
5
- @sync() count = signal(0)
12
+ @users(User) users = signal({})
13
+ @sync() count = signal(0)
6
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signe/room",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "keywords": [],
@@ -17,7 +17,7 @@
17
17
  "dset": "^3.1.3",
18
18
  "partysocket": "^1.0.1",
19
19
  "zod": "^3.23.8",
20
- "@signe/sync": "1.2.0"
20
+ "@signe/sync": "1.3.0"
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
package/src/decorators.ts CHANGED
@@ -22,7 +22,7 @@ export interface RoomOptions {
22
22
  throttleSync?: number;
23
23
  hibernate?: boolean;
24
24
  guards?: RoomGuardFn[];
25
- disconnectTimeout?: number;
25
+ sessionExpiryTime?: number;
26
26
  }
27
27
 
28
28
  export function Room(options: RoomOptions) {
@@ -31,7 +31,7 @@ export function Room(options: RoomOptions) {
31
31
  target.maxUsers = options.maxUsers;
32
32
  target.throttleStorage = options.throttleStorage;
33
33
  target.throttleSync = options.throttleSync;
34
- target.disconnectTimeout = options.disconnectTimeout ?? 0;
34
+ target.sessionExpiryTime = options.sessionExpiryTime ?? 5 * 60 * 1000;
35
35
  if (options.guards) {
36
36
  target['_roomGuards'] = options.guards;
37
37
  }
package/src/mock.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { generateShortUUID } from "../../sync/src/utils";
2
2
  import { Storage } from "./storage";
3
3
 
4
- class MockPartySocket {
4
+ export class MockPartySocket {
5
5
  private events: Map<string, Function> = new Map();
6
6
  id = generateShortUUID()
7
7
 
@@ -38,6 +38,10 @@ class MockPartyRoom {
38
38
  });
39
39
  }
40
40
 
41
+ getConnections() {
42
+ return this.clients;
43
+ }
44
+
41
45
  clear() {
42
46
  this.clients.clear();
43
47
  }
package/src/server.ts CHANGED
@@ -5,8 +5,9 @@ import {
5
5
  getByPath,
6
6
  load,
7
7
  syncClass,
8
- } from "../../sync/src";
9
- import { generateShortUUID } from "../../sync/src/utils";
8
+ DELETE_TOKEN,
9
+ generateShortUUID
10
+ } from "@signe/sync";
10
11
  import type * as Party from "./types/party";
11
12
  import {
12
13
  awaitReturn,
@@ -50,13 +51,6 @@ type CreateRoomOptions = {
50
51
  export class Server implements Party.Server {
51
52
  subRoom = null;
52
53
  rooms: any[] = [];
53
- private timeoutHandles: Map<string, any> = new Map();
54
-
55
- static async onBeforeConnect(request: Party.Request, lobby: Party.Lobby) {
56
- const token = new URL(request.url).searchParams.get("token") ?? "";
57
- request.headers.set("X-User-ID", token);
58
- return request;
59
- }
60
54
 
61
55
  /**
62
56
  * @constructor
@@ -107,13 +101,72 @@ export class Server implements Party.Server {
107
101
  }
108
102
  }
109
103
 
104
+ private async garbageCollector(options: { sessionExpiryTime: number }) {
105
+ const subRoom = await this.getSubRoom();
106
+ if (!subRoom) return;
107
+
108
+ // Get active connections
109
+ const activeConnections = [...this.room.getConnections()];
110
+ const activePrivateIds = new Set(activeConnections.map(conn => conn.id));
111
+
112
+ try {
113
+ // Get all sessions from storage
114
+ const sessions = await this.room.storage.list();
115
+ const users = this.getUsersProperty(subRoom);
116
+ const usersPropName = this.getUsersPropName(subRoom);
117
+
118
+ // Store valid publicIds from sessions
119
+ const validPublicIds = new Set<string>();
120
+ const expiredPublicIds = new Set<string>();
121
+ const SESSION_EXPIRY_TIME = options.sessionExpiryTime
122
+ const now = Date.now();
123
+
124
+ for (const [key, session] of sessions) {
125
+ // Only process session entries
126
+ if (!key.startsWith('session:')) continue;
127
+
128
+ const privateId = key.replace('session:', '');
129
+ const typedSession = session as {publicId: string, created: number, connected: boolean};
130
+
131
+ // Check if session should be deleted based on:
132
+ // 1. Connection is not active
133
+ // 2. Session is marked as disconnected
134
+ // 3. Session is older than expiry time
135
+ if (!activePrivateIds.has(privateId) &&
136
+ !typedSession.connected &&
137
+ (now - typedSession.created) > SESSION_EXPIRY_TIME) {
138
+ // Delete expired session
139
+ await this.deleteSession(privateId);
140
+ expiredPublicIds.add(typedSession.publicId);
141
+ } else if (typedSession && typedSession.publicId) {
142
+ // Keep track of valid publicIds from active or recent sessions
143
+ validPublicIds.add(typedSession.publicId);
144
+ }
145
+ }
146
+
147
+ // Clean up users only if ALL their sessions are expired
148
+ if (users && usersPropName) {
149
+ const currentUsers = users();
150
+ for (const publicId in currentUsers) {
151
+ // Only delete user if they have an expired session and no valid sessions
152
+ if (expiredPublicIds.has(publicId) && !validPublicIds.has(publicId)) {
153
+ delete currentUsers[publicId];
154
+ await this.room.storage.delete(`${usersPropName}.${publicId}`);
155
+ }
156
+ }
157
+ }
158
+
159
+ } catch (error) {
160
+ console.error('Error in garbage collector:', error);
161
+ }
162
+ }
163
+
110
164
  /**
111
165
  * @method createRoom
112
166
  * @private
113
167
  * @async
114
168
  * @param {CreateRoomOptions} [options={}] - Options for creating the room.
115
169
  * @returns {Promise<Object>} The created room instance.
116
- * @throws {Error} If no matching room is found.
117
170
  *
118
171
  * @example
119
172
  * ```typescript
@@ -127,6 +180,7 @@ export class Server implements Party.Server {
127
180
  private async createRoom(options: CreateRoomOptions = {}) {
128
181
  let instance
129
182
  let init = true
183
+ let initPersist = true
130
184
 
131
185
  // Find the appropriate room based on the current room ID
132
186
  for (let room of this.rooms) {
@@ -138,7 +192,7 @@ export class Server implements Party.Server {
138
192
  }
139
193
 
140
194
  if (!instance) {
141
- throw new Error("Room not found");
195
+ return null;
142
196
  }
143
197
 
144
198
  // Load the room's memory from storage
@@ -148,16 +202,17 @@ export class Server implements Party.Server {
148
202
  const memory = await this.room.storage.list();
149
203
  const tmpObject: any = root || {};
150
204
  for (let [key, value] of memory) {
205
+ if (key.startsWith('session:')) {
206
+ continue;
207
+ }
151
208
  if (key == ".") {
152
209
  continue;
153
210
  }
154
211
  dset(tmpObject, key, value);
155
212
  }
156
- load(instance, tmpObject);
213
+ load(instance, tmpObject, true);
157
214
  };
158
215
 
159
- await loadMemory();
160
-
161
216
  instance.$memoryAll = {}
162
217
 
163
218
  // Sync callback: Broadcast changes to all clients
@@ -180,12 +235,20 @@ export class Server implements Party.Server {
180
235
  }
181
236
 
182
237
  // Persist callback: Save changes to storage
183
- const persistCb = async (values) => {
184
- for (let path of values) {
238
+ const persistCb = async (values: Map<string, any>) => {
239
+ if (initPersist) {
240
+ values.clear();
241
+ return;
242
+ }
243
+ for (let [path, value] of values) {
185
244
  const _instance =
186
245
  path == "." ? instance : getByPath(instance, path);
187
- const itemValue = createStatesSnapshot(_instance);
188
- await this.room.storage.put(path, itemValue);
246
+ const itemValue = createStatesSnapshot(_instance);
247
+ if (value == DELETE_TOKEN) {
248
+ await this.room.storage.delete(path);
249
+ } else {
250
+ await this.room.storage.put(path, itemValue);
251
+ }
189
252
  }
190
253
  values.clear();
191
254
  }
@@ -196,6 +259,10 @@ export class Server implements Party.Server {
196
259
  onPersist: throttle(persistCb, instance["throttleStorage"] ?? 2000),
197
260
  });
198
261
 
262
+ await loadMemory();
263
+
264
+ initPersist = false
265
+
199
266
  return instance
200
267
  }
201
268
 
@@ -215,8 +282,8 @@ export class Server implements Party.Server {
215
282
  * }
216
283
  * ```
217
284
  */
218
- private async getSubRoom(options = {}) {
219
- let subRoom
285
+ private async getSubRoom(options = {}): Promise<any | null> {
286
+ let subRoom // instance of the room or null
220
287
  if (this.isHibernate) {
221
288
  subRoom = await this.createRoom(options)
222
289
  }
@@ -251,18 +318,35 @@ export class Server implements Party.Server {
251
318
  return null;
252
319
  }
253
320
 
254
- private async getSession(privateId: string): Promise<{publicId: string, state?: any} | null> {
321
+ private getUsersPropName(subRoom) {
322
+ const meta = subRoom.constructor["_propertyMetadata"];
323
+ return meta?.get("users")
324
+ }
325
+
326
+ private async getSession(privateId: string): Promise<{publicId: string, state?: any, created?: number, connected?: boolean} | null> {
255
327
  if (!privateId) return null;
256
328
  try {
257
329
  const session = await this.room.storage.get(`session:${privateId}`);
258
- return session as {publicId: string, state?: any} | null;
330
+ return session as {publicId: string, state?: any, created: number, connected: boolean} | null;
259
331
  } catch (e) {
260
332
  return null;
261
333
  }
262
334
  }
263
335
 
264
- private async saveSession(privateId: string, data: {publicId: string, state?: any}) {
265
- await this.room.storage.put(`session:${privateId}`, data);
336
+ private async saveSession(privateId: string, data: {publicId: string, state?: any, created?: number, connected?: boolean}) {
337
+ const sessionData = {
338
+ ...data,
339
+ created: data.created || Date.now(),
340
+ connected: data.connected !== undefined ? data.connected : true
341
+ };
342
+ await this.room.storage.put(`session:${privateId}`, sessionData);
343
+ }
344
+
345
+ private async updateSessionConnection(privateId: string, connected: boolean) {
346
+ const session = await this.getSession(privateId);
347
+ if (session) {
348
+ await this.saveSession(privateId, { ...session, connected });
349
+ }
266
350
  }
267
351
 
268
352
  private async deleteSession(privateId: string) {
@@ -290,6 +374,14 @@ export class Server implements Party.Server {
290
374
  getMemoryAll: true,
291
375
  })
292
376
 
377
+ if (!subRoom) {
378
+ conn.close();
379
+ return;
380
+ }
381
+
382
+ const sessionExpiryTime = subRoom.constructor.sessionExpiryTime;
383
+ await this.garbageCollector({ sessionExpiryTime });
384
+
293
385
  // Check room guards
294
386
  const roomGuards = subRoom.constructor['_roomGuards'] || [];
295
387
  for (const guard of roomGuards) {
@@ -301,40 +393,42 @@ export class Server implements Party.Server {
301
393
  }
302
394
 
303
395
  // Check for existing session
304
- const providedPrivateId = ctx.request?.headers.get("X-User-ID");
305
- const existingSession = providedPrivateId ? await this.getSession(providedPrivateId) : null;
396
+ const existingSession = await this.getSession(conn.id)
306
397
 
307
398
  // Generate IDs
308
399
  const publicId = existingSession?.publicId || generateShortUUID();
309
- const privateId = existingSession ? providedPrivateId : generateShortUUID();
310
400
 
311
401
  let user = null;
312
402
  const signal = this.getUsersProperty(subRoom);
313
-
403
+ const usersPropName = this.getUsersPropName(subRoom);
404
+
314
405
  if (signal) {
315
406
  const { classType } = signal.options;
316
- user = isClass(classType) ? new classType() : classType(conn, ctx);
317
-
407
+
318
408
  // Restore state if exists
319
- if (existingSession?.state) {
320
- Object.assign(user, existingSession.state);
409
+ if (!existingSession?.publicId) {
410
+ user = isClass(classType) ? new classType() : classType(conn, ctx);
411
+ signal()[publicId] = user;
412
+ const snapshot = createStatesSnapshot(user);
413
+ this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
321
414
  }
322
415
 
323
- signal()[publicId] = user;
324
-
325
416
  // Only store new session if it doesn't exist
326
417
  if (!existingSession) {
327
- await this.saveSession(privateId, {
418
+ await this.saveSession(conn.id, {
328
419
  publicId
329
420
  });
330
421
  }
422
+ else {
423
+ await this.updateSessionConnection(conn.id, true);
424
+ }
331
425
  }
332
426
 
333
427
  // Call the room's onJoin method if it exists
334
428
  await awaitReturn(subRoom["onJoin"]?.(user, conn, ctx));
335
429
 
336
430
  // Store both IDs in connection state
337
- conn.setState({ publicId, privateId });
431
+ conn.setState({ publicId });
338
432
 
339
433
  // Send initial sync data with both IDs to the new connection
340
434
  conn.send(
@@ -342,7 +436,6 @@ export class Server implements Party.Server {
342
436
  type: "sync",
343
437
  value: {
344
438
  pId: publicId,
345
- privateId,
346
439
  ...subRoom.$memoryAll,
347
440
  },
348
441
  })
@@ -380,7 +473,6 @@ export class Server implements Party.Server {
380
473
  return;
381
474
  }
382
475
  const subRoom = await this.getSubRoom()
383
-
384
476
  // Check room guards
385
477
  const roomGuards = subRoom.constructor['_roomGuards'] || [];
386
478
  for (const guard of roomGuards) {
@@ -441,78 +533,67 @@ export class Server implements Party.Server {
441
533
  */
442
534
  async onClose(conn: Party.Connection) {
443
535
  const subRoom = await this.getSubRoom()
536
+
537
+ if (!subRoom) {
538
+ return;
539
+ }
540
+
444
541
  const signal = this.getUsersProperty(subRoom);
542
+
445
543
  if (!conn.state) {
446
544
  return;
447
545
  }
448
- const { publicId, privateId } = conn.state as any;
546
+
547
+ const privateId = conn.id;
548
+ const { publicId } = conn.state as any;
449
549
  const user = signal?.()[publicId];
450
-
451
- if (!user) return;
452
550
 
453
- // Save current state
454
- if (privateId) {
455
- await this.saveSession(privateId, {
456
- publicId,
457
- state: { ...user }
458
- });
459
- }
551
+ if (!user) return;
460
552
 
461
- // Clear any existing timeout for this user
462
- const existingTimeout = this.timeoutHandles.get(privateId);
463
- if (existingTimeout) {
464
- clearTimeout(existingTimeout);
465
- this.timeoutHandles.delete(privateId);
466
- }
553
+ await awaitReturn(subRoom["onLeave"]?.(user, conn));
467
554
 
468
- const cleanup = async () => {
469
- // Call onLeave hook
470
- await awaitReturn(subRoom["onLeave"]?.(user, conn));
471
-
472
- // Remove user from signal
473
- if (signal) {
474
- delete signal()[publicId];
475
- }
476
-
477
- // Delete session
478
- if (privateId) {
479
- await this.deleteSession(privateId);
480
- }
555
+ // Mark session as disconnected instead of deleting it
556
+ await this.updateSessionConnection(privateId, false);
481
557
 
482
- // Broadcast user disconnection
483
- this.room.broadcast(
484
- JSON.stringify({
485
- type: "user_disconnected",
486
- value: { publicId }
487
- })
488
- );
558
+ // Broadcast user disconnection
559
+ this.room.broadcast(
560
+ JSON.stringify({
561
+ type: "user_disconnected",
562
+ value: { publicId }
563
+ })
564
+ );
565
+ }
489
566
 
490
- // Clear timeout handle
491
- this.timeoutHandles.delete(privateId);
492
- };
567
+ async onAlarm() {
568
+ const subRoom = await this.getSubRoom()
569
+ await awaitReturn(subRoom["onAlarm"]?.(subRoom));
570
+ }
493
571
 
494
- const disconnectTimeout = subRoom.constructor.disconnectTimeout ?? 0;
495
-
496
- if (disconnectTimeout > 0) {
497
- // Set temporary offline status
498
- if (user.status) {
499
- user.status.set('offline');
500
- }
572
+ async onError(connection: Party.Connection, error: Error) {
573
+ const subRoom = await this.getSubRoom()
574
+ await awaitReturn(subRoom["onError"]?.(connection, error));
575
+ }
501
576
 
502
- // Broadcast temporary disconnection
503
- this.room.broadcast(
504
- JSON.stringify({
505
- type: "user_offline",
506
- value: { publicId }
507
- })
508
- );
577
+ async onRequest(req: Party.Request) {
578
+ const subRoom = await this.getSubRoom()
579
+ const res = (body: any, status: number) => {
580
+ return new Response(JSON.stringify(body), { status });
581
+ }
582
+ if (!subRoom) {
583
+ return res({
584
+ error: "Not found"
585
+ }, 404);
586
+ }
509
587
 
510
- // Set cleanup timeout
511
- const timeout = setTimeout(cleanup, disconnectTimeout);
512
- this.timeoutHandles.set(privateId, timeout);
513
- } else {
514
- // Immediate cleanup if no timeout
515
- await cleanup();
588
+ const response = await awaitReturn(subRoom["onRequest"]?.(req, this.room));
589
+ if (!response) {
590
+ return res({
591
+ error: "Not found"
592
+ }, 404);
593
+ }
594
+ if (response instanceof Response) {
595
+ return response;
516
596
  }
597
+ return res(response, 200);
517
598
  }
518
599
  }