@signe/room 1.2.1 → 1.4.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.1",
3
+ "version": "1.4.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.1"
20
+ "@signe/sync": "1.4.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,16 +393,15 @@ 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
407
 
@@ -318,21 +409,26 @@ export class Server implements Party.Server {
318
409
  if (!existingSession?.publicId) {
319
410
  user = isClass(classType) ? new classType() : classType(conn, ctx);
320
411
  signal()[publicId] = user;
412
+ const snapshot = createStatesSnapshot(user);
413
+ this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
321
414
  }
322
415
 
323
416
  // Only store new session if it doesn't exist
324
417
  if (!existingSession) {
325
- await this.saveSession(privateId, {
418
+ await this.saveSession(conn.id, {
326
419
  publicId
327
420
  });
328
421
  }
422
+ else {
423
+ await this.updateSessionConnection(conn.id, true);
424
+ }
329
425
  }
330
426
 
331
427
  // Call the room's onJoin method if it exists
332
428
  await awaitReturn(subRoom["onJoin"]?.(user, conn, ctx));
333
429
 
334
430
  // Store both IDs in connection state
335
- conn.setState({ publicId, privateId });
431
+ conn.setState({ publicId });
336
432
 
337
433
  // Send initial sync data with both IDs to the new connection
338
434
  conn.send(
@@ -340,7 +436,6 @@ export class Server implements Party.Server {
340
436
  type: "sync",
341
437
  value: {
342
438
  pId: publicId,
343
- privateId,
344
439
  ...subRoom.$memoryAll,
345
440
  },
346
441
  })
@@ -378,7 +473,6 @@ export class Server implements Party.Server {
378
473
  return;
379
474
  }
380
475
  const subRoom = await this.getSubRoom()
381
-
382
476
  // Check room guards
383
477
  const roomGuards = subRoom.constructor['_roomGuards'] || [];
384
478
  for (const guard of roomGuards) {
@@ -439,82 +533,67 @@ export class Server implements Party.Server {
439
533
  */
440
534
  async onClose(conn: Party.Connection) {
441
535
  const subRoom = await this.getSubRoom()
442
- const signal = this.getUsersProperty(subRoom);
443
- if (!conn.state) {
536
+
537
+ if (!subRoom) {
444
538
  return;
445
539
  }
446
- const { publicId, privateId } = conn.state as any;
447
- const user = signal?.()[publicId];
448
-
449
- if (!user) return;
450
540
 
451
- // Save current state
452
- if (privateId) {
453
- await this.saveSession(privateId, {
454
- publicId
455
- });
456
- }
541
+ const signal = this.getUsersProperty(subRoom);
457
542
 
458
- // Clear any existing timeout for this user
459
- const existingTimeout = this.timeoutHandles.get(privateId);
460
- if (existingTimeout) {
461
- clearTimeout(existingTimeout);
462
- this.timeoutHandles.delete(privateId);
543
+ if (!conn.state) {
544
+ return;
463
545
  }
464
546
 
465
- const cleanup = async () => {
466
- // Call onLeave hook
467
- await awaitReturn(subRoom["onLeave"]?.(user, conn));
468
-
469
- // Remove user from signal
470
- if (signal) {
471
- delete signal()[publicId];
472
- }
473
-
474
- // Delete session
475
- if (privateId) {
476
- await this.deleteSession(privateId);
477
- }
478
-
479
- // Broadcast user disconnection
480
- this.room.broadcast(
481
- JSON.stringify({
482
- type: "user_disconnected",
483
- value: { publicId }
484
- })
485
- );
547
+ const privateId = conn.id;
548
+ const { publicId } = conn.state as any;
549
+ const user = signal?.()[publicId];
486
550
 
487
- // Clear timeout handle
488
- this.timeoutHandles.delete(privateId);
489
- };
551
+ if (!user) return;
490
552
 
491
- const disconnectTimeout = subRoom.constructor.disconnectTimeout ?? 0;
492
-
493
- if (disconnectTimeout > 0) {
494
- // Set temporary offline status
495
- if (user.status) {
496
- user.status.set('offline');
497
- }
553
+ await awaitReturn(subRoom["onLeave"]?.(user, conn));
498
554
 
499
- // Broadcast temporary disconnection
500
- this.room.broadcast(
501
- JSON.stringify({
502
- type: "user_offline",
503
- value: { publicId }
504
- })
505
- );
555
+ // Mark session as disconnected instead of deleting it
556
+ await this.updateSessionConnection(privateId, false);
506
557
 
507
- // Set cleanup timeout
508
- const timeout = setTimeout(cleanup, disconnectTimeout);
509
- this.timeoutHandles.set(privateId, timeout);
510
- } else {
511
- // Immediate cleanup if no timeout
512
- await cleanup();
513
- }
558
+ // Broadcast user disconnection
559
+ this.room.broadcast(
560
+ JSON.stringify({
561
+ type: "user_disconnected",
562
+ value: { publicId }
563
+ })
564
+ );
514
565
  }
515
566
 
516
567
  async onAlarm() {
517
568
  const subRoom = await this.getSubRoom()
518
569
  await awaitReturn(subRoom["onAlarm"]?.(subRoom));
519
570
  }
571
+
572
+ async onError(connection: Party.Connection, error: Error) {
573
+ const subRoom = await this.getSubRoom()
574
+ await awaitReturn(subRoom["onError"]?.(connection, error));
575
+ }
576
+
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
+ }
587
+
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;
596
+ }
597
+ return res(response, 200);
598
+ }
520
599
  }