@signe/room 2.3.3 → 2.4.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.
@@ -1,12 +1,13 @@
1
1
  import { createRoot } from "react-dom/client";
2
2
  import Admin from "./components/Admin";
3
3
  import "./styles.css";
4
+ import Room from "./components/Room";
4
5
 
5
6
 
6
7
  function App() {
7
8
  return (
8
9
  <main>
9
- <Admin />
10
+ <Room />
10
11
  </main>
11
12
  );
12
13
  }
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
- import { connectionWorld } from '../../../../../sync/src/client';
2
+ import { connectionRoom, connectionWorld } from '../../../../../sync/src/client';
3
3
  import { RoomSchema } from "../../shared/room.schema";
4
4
  import { effect } from "@signe/reactive";
5
5
 
@@ -7,7 +7,7 @@ export default function Room() {
7
7
  const [isConnected, setIsConnected] = useState(false);
8
8
  const [isConnecting, setIsConnecting] = useState(false);
9
9
  const [error, setError] = useState<string | null>(null);
10
- const [roomId, setRoomId] = useState("quiz");
10
+ const [roomId, setRoomId] = useState("game");
11
11
  const [count, setCount] = useState(0);
12
12
  const socketRef = useRef<any>(null);
13
13
  const roomRef = useRef<any>(null);
@@ -21,11 +21,15 @@ export default function Room() {
21
21
  roomRef.current = new RoomSchema();
22
22
 
23
23
  // Connect to the room through the World service with auto-creation enabled
24
- socketRef.current = await connectionWorld({
24
+ socketRef.current = await connectionRoom({
25
25
  host: 'http://localhost:1999',
26
+ id: 'test',
26
27
  room: roomId,
27
- autoCreate: true // Enable auto-creation of room and shards
28
28
  }, roomRef.current);
29
+
30
+ socketRef.current.on('sync', (data) => {
31
+ console.log('sync', data);
32
+ })
29
33
 
30
34
  // Listen for disconnection events
31
35
  socketRef.current.on('disconnect', () => {
@@ -1,4 +1,4 @@
1
- import { Action, Guard, Room } from "../../../src";
1
+ import { Action, Guard, requireSession, Room } from "../../../src";
2
2
  import { RoomSchema } from "../shared/room.schema";
3
3
 
4
4
  @Room({
@@ -10,10 +10,23 @@ export class GameRoom extends RoomSchema {
10
10
  increment(player) {
11
11
  this.count.update((count) => count + 1);
12
12
  player.score.update((score) => score + 1);
13
+ this.$sessionTransfer(player.id(), "protected-1");
13
14
  }
14
15
 
15
16
  async onRequest(req: Party.Request, room: any) {
16
17
  const map = await room.storage.list() as Map<string, any>;
17
18
  return Object.fromEntries(map);
18
19
  }
20
+ }
21
+
22
+ @Room({
23
+ path: 'protected-{gameId}',
24
+ sessionExpiryTime: 5000,
25
+ guards: [requireSession]
26
+ })
27
+ export class ProtectedRoom extends RoomSchema {
28
+ @Action('increment')
29
+ increment(player) {
30
+ console.log('increment', player);
31
+ }
19
32
  }
@@ -1,10 +1,10 @@
1
1
  import { Server, WorldRoom } from '../../../src';
2
2
  import type * as Party from "../../../src/types/party";
3
- import { GameRoom } from "./game.room";
3
+ import { GameRoom, ProtectedRoom } from "./game.room";
4
4
 
5
5
  export default class MainServer extends Server {
6
6
  rooms = [
7
7
  GameRoom ,
8
- WorldRoom
8
+ ProtectedRoom
9
9
  ]
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signe/room",
3
- "version": "2.3.3",
3
+ "version": "2.4.1",
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": "2.3.3"
20
+ "@signe/sync": "2.4.1"
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
package/src/index.ts CHANGED
@@ -5,4 +5,5 @@ export * from './testing';
5
5
  export * from './shard';
6
6
  export * from './world';
7
7
  export * from './interfaces';
8
- export * from './request/response';
8
+ export * from './request/response';
9
+ export { requireSession, createRequireSessionGuard } from './session.guard';
package/src/mock.ts CHANGED
@@ -47,14 +47,15 @@ export class MockPartyClient {
47
47
  }
48
48
 
49
49
  class MockLobby {
50
- constructor(public server: Server) {}
50
+ constructor(public server: Server, public lobbyId: string) {}
51
51
 
52
52
  socket() {
53
53
  return new MockPartyClient(this.server)
54
54
  }
55
55
 
56
56
  fetch(url: string, options: any) {
57
- return request(this.server, url, options)
57
+ const baseUrl = url.includes('shard') ? '' :( '/parties/main/' + this.lobbyId )
58
+ return request(this.server, baseUrl + url, options)
58
59
  }
59
60
  }
60
61
 
@@ -68,7 +69,7 @@ class MockContext {
68
69
  constructor(public room: MockPartyRoom, options: any = {}) {
69
70
  const parties = options.parties || {}
70
71
  for (let lobbyId in parties) {
71
- this.parties.main.set(lobbyId, new MockLobby(parties[lobbyId](room)))
72
+ this.parties.main.set(lobbyId, new MockLobby(parties[lobbyId](room), lobbyId))
72
73
  }
73
74
  }
74
75
  }
package/src/server.ts CHANGED
@@ -245,6 +245,126 @@ export class Server implements Party.Server {
245
245
  instance.$broadcast = (obj: any) => {
246
246
  return this.broadcast(obj, instance)
247
247
  }
248
+ instance.$sessionTransfer = async (userOrPublicId: any | string, targetRoomId: string) => {
249
+ let user: any;
250
+ let publicId: string | null = null;
251
+
252
+ const signal = this.getUsersProperty(instance);
253
+ if (!signal) {
254
+ console.error('[sessionTransfer] `users` property not defined in the room.');
255
+ return null;
256
+ }
257
+
258
+ // Check if the first parameter is a string (publicId) or an object (user)
259
+ if (typeof userOrPublicId === 'string') {
260
+ publicId = userOrPublicId;
261
+ user = signal()[publicId];
262
+ if (!user) {
263
+ console.error(`[sessionTransfer] User with publicId ${publicId} not found.`);
264
+ return null;
265
+ }
266
+ } else {
267
+ user = userOrPublicId;
268
+ const users = signal();
269
+
270
+ // Try to find the publicId by comparing object references
271
+ for (const [id, u] of Object.entries(users)) {
272
+ if (u === user) {
273
+ publicId = id;
274
+ break;
275
+ }
276
+ }
277
+
278
+ // If not found by reference, try to find by user properties (fallback)
279
+ if (!publicId && user && typeof user === 'object') {
280
+ // Look for a unique identifier in the user object
281
+ for (const [id, u] of Object.entries(users)) {
282
+ if (u && typeof u === 'object') {
283
+ // Compare by constructor and other identifying properties
284
+ if (u.constructor === user.constructor) {
285
+ // Additional checks could be added here based on user structure
286
+ publicId = id;
287
+ break;
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ if (!publicId) {
294
+ console.error('[sessionTransfer] User not found in users collection.', {
295
+ userType: user?.constructor?.name,
296
+ userKeys: user ? Object.keys(user) : 'null',
297
+ usersCount: Object.keys(users).length,
298
+ userIds: Object.keys(users)
299
+ });
300
+ return null;
301
+ }
302
+ }
303
+
304
+ const sessions = await this.room.storage.list();
305
+ let userSession: any = null;
306
+ let privateId: string | null = null;
307
+
308
+ for (const [key, session] of sessions) {
309
+ if (key.startsWith('session:') && (session as any).publicId === publicId) {
310
+ userSession = session;
311
+ privateId = key.replace('session:', '');
312
+ break;
313
+ }
314
+ }
315
+
316
+ if (!userSession || !privateId) {
317
+ console.error(`[sessionTransfer] Session for publicId ${publicId} not found.`);
318
+ return null;
319
+ }
320
+
321
+ const usersPropName = this.getUsersPropName(instance);
322
+ if (!usersPropName) {
323
+ console.error('[sessionTransfer] `users` property not defined in the room.');
324
+ return null;
325
+ }
326
+
327
+ // Create a snapshot of the user state
328
+ const userSnapshot = createStatesSnapshot(user);
329
+
330
+ const transferData = {
331
+ privateId,
332
+ userSnapshot,
333
+ sessionState: userSession.state,
334
+ publicId
335
+ };
336
+
337
+ try {
338
+ const targetRoomParty = this.room.context.parties.main.get(targetRoomId);
339
+ const response = await targetRoomParty.fetch('/session-transfer', {
340
+ method: 'POST',
341
+ body: JSON.stringify(transferData),
342
+ headers: {
343
+ 'Content-Type': 'application/json'
344
+ }
345
+ });
346
+
347
+ if (!response.ok) {
348
+ throw new Error(`Transfer request failed: ${await response.text()}`);
349
+ }
350
+
351
+ const { transferToken } = await response.json();
352
+
353
+ // On success, remove user from current room
354
+ await this.deleteSession(privateId);
355
+ await this.room.storage.delete(`${usersPropName}.${publicId}`);
356
+
357
+ const currentUsers = signal();
358
+ if (currentUsers[publicId]) {
359
+ delete currentUsers[publicId];
360
+ }
361
+
362
+ return transferToken;
363
+ } catch (error) {
364
+ console.error(`[sessionTransfer] Failed to transfer session to room ${targetRoomId}:`, error);
365
+ return null;
366
+ }
367
+ };
248
368
 
249
369
  // Sync callback: Broadcast changes to all clients
250
370
  const syncCb = (values) => {
@@ -464,18 +584,32 @@ export class Server implements Party.Server {
464
584
  // Check room guards
465
585
  const roomGuards = subRoom.constructor['_roomGuards'] || [];
466
586
  for (const guard of roomGuards) {
467
- const isAuthorized = await guard(conn, ctx);
587
+ const isAuthorized = await guard(conn, ctx, this.room);
468
588
  if (!isAuthorized) {
469
589
  conn.close();
470
590
  return;
471
591
  }
472
592
  }
473
593
 
594
+ // Handle session transfer
595
+ let transferToken = null;
596
+ if (ctx.request?.url) {
597
+ const url = new URL(ctx.request.url);
598
+ transferToken = url.searchParams.get('transferToken');
599
+ }
600
+ let transferData: any = null;
601
+ if (transferToken) {
602
+ transferData = await this.room.storage.get(`transfer:${transferToken}`);
603
+ if (transferData) {
604
+ await this.room.storage.delete(`transfer:${transferToken}`);
605
+ }
606
+ }
607
+
474
608
  // Check for existing session
475
609
  const existingSession = await this.getSession(conn.id)
476
610
 
477
611
  // Generate IDs
478
- const publicId = existingSession?.publicId || generateShortUUID();
612
+ const publicId = existingSession?.publicId || transferData?.publicId || generateShortUUID();
479
613
 
480
614
  let user = null;
481
615
  const signal = this.getUsersProperty(subRoom);
@@ -486,10 +620,15 @@ export class Server implements Party.Server {
486
620
 
487
621
  // Restore state if exists
488
622
  if (!existingSession?.publicId) {
489
- user = isClass(classType) ? new classType() : classType(conn, ctx);
490
- signal()[publicId] = user;
491
- const snapshot = createStatesSnapshot(user);
492
- this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
623
+ // Check if we have a transferred user already restored
624
+ if (transferData?.restored && signal()[publicId]) {
625
+ user = signal()[publicId];
626
+ } else {
627
+ user = isClass(classType) ? new classType() : classType(conn, ctx);
628
+ signal()[publicId] = user;
629
+ const snapshot = createStatesSnapshot(user);
630
+ this.room.storage.put(`${usersPropName}.${publicId}`, snapshot);
631
+ }
493
632
  }
494
633
  else {
495
634
  user = signal()[existingSession.publicId];
@@ -497,7 +636,9 @@ export class Server implements Party.Server {
497
636
 
498
637
  // Only store new session if it doesn't exist
499
638
  if (!existingSession) {
500
- await this.saveSession(conn.id, {
639
+ // Use the transferred privateId if available, otherwise use connection id
640
+ const sessionPrivateId = transferData?.privateId || conn.id;
641
+ await this.saveSession(sessionPrivateId, {
501
642
  publicId
502
643
  });
503
644
  }
@@ -858,7 +999,7 @@ export class Server implements Party.Server {
858
999
  * @method onClose
859
1000
  * @async
860
1001
  * @param {Party.Connection} conn - The connection object of the disconnecting user.
861
- * @description Handles user disconnection, removing them from the room and triggering the onLeave event.
1002
+ * @description Handles user disconnection, removing them from the room and triggering the onLeave event..
862
1003
  * @returns {Promise<void>}
863
1004
  *
864
1005
  * @example
@@ -946,6 +1087,80 @@ export class Server implements Party.Server {
946
1087
  return this.handleDirectRequest(req, res);
947
1088
  }
948
1089
 
1090
+
1091
+ /**
1092
+ * @method handleSessionRestore
1093
+ * @private
1094
+ * @async
1095
+ * @param {Party.Request} req - The HTTP request for session restore
1096
+ * @param {ServerResponse} res - The response object
1097
+ * @description Handles session restoration from transfer data, creates session from privateId
1098
+ * @returns {Promise<Response>} The response to return to the client
1099
+ */
1100
+ private async handleSessionRestore(req: Party.Request, res: ServerResponse): Promise<Response> {
1101
+ try {
1102
+ const transferData = await req.json() as {
1103
+ privateId: string;
1104
+ userSnapshot?: any;
1105
+ sessionState?: any;
1106
+ publicId: string;
1107
+ };
1108
+ const { privateId, userSnapshot, sessionState, publicId } = transferData;
1109
+
1110
+ if (!privateId || !publicId) {
1111
+ return res.badRequest('Missing privateId or publicId in transfer data');
1112
+ }
1113
+
1114
+ const subRoom = await this.getSubRoom();
1115
+ if (!subRoom) {
1116
+ return res.serverError('Room not available');
1117
+ }
1118
+
1119
+ // Create session from privateId
1120
+ await this.saveSession(privateId, {
1121
+ publicId,
1122
+ state: sessionState,
1123
+ created: Date.now(),
1124
+ connected: false // Will be set to true when user connects
1125
+ });
1126
+
1127
+ // If userSnapshot exists, restore user data
1128
+ if (userSnapshot) {
1129
+ const signal = this.getUsersProperty(subRoom);
1130
+ const usersPropName = this.getUsersPropName(subRoom);
1131
+
1132
+ if (signal && usersPropName) {
1133
+ const { classType } = signal.options;
1134
+
1135
+ // Create new user instance
1136
+ const user = isClass(classType) ? new classType() : classType();
1137
+
1138
+ // Load user data from snapshot
1139
+ load(user, userSnapshot, true);
1140
+
1141
+ // Add user to signal
1142
+ signal()[publicId] = user;
1143
+
1144
+ // Save user snapshot to storage
1145
+ await this.room.storage.put(`${usersPropName}.${publicId}`, userSnapshot);
1146
+ }
1147
+ }
1148
+
1149
+ // Generate transfer token for the client to use when connecting
1150
+ const transferToken = generateShortUUID();
1151
+ await this.room.storage.put(`transfer:${transferToken}`, {
1152
+ privateId,
1153
+ publicId,
1154
+ restored: true
1155
+ });
1156
+
1157
+ return res.success({ transferToken });
1158
+ } catch (error) {
1159
+ console.error('Error restoring session:', error);
1160
+ return res.serverError('Failed to restore session');
1161
+ }
1162
+ }
1163
+
949
1164
  /**
950
1165
  * @method handleDirectRequest
951
1166
  * @private
@@ -956,10 +1171,16 @@ export class Server implements Party.Server {
956
1171
  */
957
1172
  private async handleDirectRequest(req: Party.Request, res: ServerResponse): Promise<Response> {
958
1173
  const subRoom = await this.getSubRoom();
1174
+
959
1175
  if (!subRoom) {
960
1176
  return res.notFound();
961
1177
  }
962
1178
 
1179
+ const url = new URL(req.url);
1180
+ if (url.pathname.endsWith('/session-transfer') && req.method === 'POST') {
1181
+ return this.handleSessionRestore(req, res);
1182
+ }
1183
+
963
1184
  // First try to match using the registered @Request handlers
964
1185
  const response = await this.tryMatchRequestHandler(req, res, subRoom);
965
1186
  if (response) {
@@ -1083,7 +1304,7 @@ export class Server implements Party.Server {
1083
1304
  .replace(/\//g, '\\/') // Escape slashes
1084
1305
  .replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
1085
1306
 
1086
- const pathRegex = new RegExp(`^${pathRegexString}$`);
1307
+ const pathRegex = new RegExp(`^${pathRegexString}`);
1087
1308
  return pathRegex.test(requestPath);
1088
1309
  }
1089
1310
 
@@ -1111,7 +1332,7 @@ export class Server implements Party.Server {
1111
1332
  .replace(/\//g, '\\/') // Escape slashes
1112
1333
  .replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
1113
1334
 
1114
- const pathRegex = new RegExp(`^${pathRegexString}$`);
1335
+ const pathRegex = new RegExp(`^${pathRegexString}`);
1115
1336
  const matches = requestPath.match(pathRegex);
1116
1337
 
1117
1338
  if (matches && matches.length > 1) {
@@ -0,0 +1,111 @@
1
+ import type * as Party from "./types/party";
2
+
3
+ /**
4
+ * @description Factory function that creates a session guard with access to room storage
5
+ * @param {Party.Storage} storage - The room storage instance
6
+ * @returns {Function} - The guard function
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { createRequireSessionGuard } from "./session.guard";
11
+ *
12
+ * export class GameRoom {
13
+ * constructor(private room: Party.Room) {}
14
+ *
15
+ * @Action("sendMessage")
16
+ * @Guard([createRequireSessionGuard(this.room.storage)])
17
+ * async sendMessage(user: User, message: string, conn: Party.Connection) {
18
+ * // This action will only execute if the user has a valid session
19
+ * this.$broadcast({ type: "message", user, message });
20
+ * }
21
+ * }
22
+ * ```
23
+ */
24
+ export function createRequireSessionGuard(storage: Party.Storage) {
25
+ return async (sender: Party.Connection, value: any): Promise<boolean> => {
26
+ if (!sender || !sender.id) {
27
+ return false;
28
+ }
29
+
30
+ try {
31
+ // Check if session exists in storage
32
+ const session = await storage.get(`session:${sender.id}`);
33
+
34
+ // Return false if no session found
35
+ if (!session) {
36
+ return false;
37
+ }
38
+
39
+ // Verify session has required properties
40
+ const typedSession = session as { publicId: string, created?: number, connected?: boolean };
41
+ if (!typedSession.publicId) {
42
+ return false;
43
+ }
44
+
45
+ // Session exists and is valid
46
+ return true;
47
+ } catch (error) {
48
+ // If there's an error accessing storage, deny access
49
+ console.error('Error checking session in requireSession guard:', error);
50
+ return false;
51
+ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @description Guard function that verifies if a user session exists (for room and request guards)
57
+ * @param {Party.Connection} sender - The connection object of the sender
58
+ * @param {any} value - The value/payload sent with the action or request
59
+ * @param {Party.Room} room - The room instance
60
+ * @returns {Promise<boolean>} - Returns true if session exists, false otherwise
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * import { requireSession } from "./session.guard";
65
+ *
66
+ * // For room guards
67
+ * @Room({
68
+ * path: "game-{id}",
69
+ * guards: [requireSession]
70
+ * })
71
+ * export class GameRoom {
72
+ * // Room implementation
73
+ * }
74
+ *
75
+ * // For request guards
76
+ * @Request({ path: '/api/data', method: 'GET' })
77
+ * @Guard([requireSession])
78
+ * async getData(req: Party.Request, res: ServerResponse) {
79
+ * // This request will only execute if the user has a valid session
80
+ * return res.success({ data: "protected data" });
81
+ * }
82
+ * ```
83
+ */
84
+ export const requireSession = async (sender: Party.Connection, value: any, room: Party.Room): Promise<boolean> => {
85
+ if (!sender || !sender.id) {
86
+ return false;
87
+ }
88
+
89
+ try {
90
+ // Check if session exists in storage
91
+ const session = await room.storage.get(`session:${sender.id}`);
92
+
93
+ // Return false if no session found
94
+ if (!session) {
95
+ return false;
96
+ }
97
+
98
+ // Verify session has required properties
99
+ const typedSession = session as { publicId: string, created?: number, connected?: boolean };
100
+ if (!typedSession.publicId) {
101
+ return false;
102
+ }
103
+
104
+ // Session exists and is valid
105
+ return true;
106
+ } catch (error) {
107
+ // If there's an error accessing storage, deny access
108
+ console.error('Error checking session in requireSession guard:', error);
109
+ return false;
110
+ }
111
+ };