@signe/room 2.8.3 → 2.9.2

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.
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@signe/room",
3
- "version": "2.8.3",
4
- "description": "",
3
+ "version": "2.9.2",
4
+ "description": "PartyKit room primitives with synchronized state, sessions, guards, and HTTP handlers.",
5
5
  "main": "./dist/index.js",
6
- "keywords": [],
6
+ "keywords": [
7
+ "partykit",
8
+ "realtime",
9
+ "websocket",
10
+ "cloudflare",
11
+ "typescript"
12
+ ],
7
13
  "author": "Samuel Ronce",
8
14
  "type": "module",
9
15
  "exports": {
@@ -17,7 +23,7 @@
17
23
  "dset": "^3.1.3",
18
24
  "partysocket": "^1.0.1",
19
25
  "zod": "^3.23.8",
20
- "@signe/sync": "2.8.3"
26
+ "@signe/sync": "2.9.2"
21
27
  },
22
28
  "publishConfig": {
23
29
  "access": "public"
package/readme.md CHANGED
@@ -69,13 +69,61 @@ Function have to be decorated with the `@Action` decorator and have 3 parameter
69
69
  - The second parameter is the value of the action
70
70
  - The third parameter is the Party.Connection instance
71
71
 
72
+ ### Unhandled actions
73
+
74
+ If you want to catch any valid WebSocket message whose `action` is not registered
75
+ with `@Action(...)`, you can use `@UnhandledAction()`.
76
+
77
+ The fallback handler receives:
78
+
79
+ - The first parameter is the player instance
80
+ - The second parameter is the full message object: `{ action, value }`
81
+ - The third parameter is the `Party.Connection` instance
82
+
83
+ ```ts
84
+ import { Action, Guard, Room, UnhandledAction } from "@signe/room";
85
+
86
+ function isAuthenticated(conn: Party.Connection, value: any, room: Party.Room) {
87
+ return !!conn.state?.publicId;
88
+ }
89
+
90
+ @Room({
91
+ path: "game",
92
+ })
93
+ class GameRoom {
94
+ @Action("move")
95
+ move(player: any, value: { x: number; y: number }) {
96
+ player.x.set(value.x);
97
+ player.y.set(value.y);
98
+ }
99
+
100
+ @UnhandledAction()
101
+ @Guard([isAuthenticated])
102
+ onUnhandledAction(
103
+ player: any,
104
+ message: { action: string; value: unknown },
105
+ conn: Party.Connection
106
+ ) {
107
+ console.warn("Unhandled action", message.action, message.value, conn.id);
108
+ }
109
+ }
110
+ ```
111
+
112
+ Notes:
113
+
114
+ - `@UnhandledAction()` is only called if the incoming message matches the expected
115
+ WebSocket shape `{ action, value }`
116
+ - If a matching `@Action("...")` exists, it always has priority over
117
+ `@UnhandledAction()`
118
+ - You can combine `@UnhandledAction()` with `@Guard(...)`
119
+
72
120
  ## HTTP Request Handling
73
121
 
74
122
  The `@Request` decorator allows you to handle HTTP requests with specific routes and methods:
75
123
 
76
124
  ```ts
77
125
  import { z } from "zod";
78
- import { Room, Request, RequestGuard, ServerResponse } from "@signe/room";
126
+ import { Guard, Room, Request, ServerResponse } from "@signe/room";
79
127
 
80
128
  @Room({
81
129
  path: "api"
@@ -121,11 +169,12 @@ class ApiRoom {
121
169
  }
122
170
  ```
123
171
 
124
- Request handler methods receive these parameters:
125
- 1. `req`: The original Party.Request object
126
- 2. `body`: The validated request body (if validation schema was provided)
127
- 3. `params`: An object containing any path parameters
128
- 4. `room`: The Party.Room instance
172
+ Request handler methods receive:
173
+
174
+ 1. `req`: the original `Party.Request`, extended with `req.params` and
175
+ `req.data` when a validation schema is provided.
176
+ 2. `res`: a `ServerResponse` helper for JSON, text, redirects, and common
177
+ error responses.
129
178
 
130
179
  You can return:
131
180
  - A Response object for complete control
package/src/decorators.ts CHANGED
@@ -15,6 +15,18 @@ export function Action(name: string, bodyValidation?: z.ZodSchema) {
15
15
  };
16
16
  }
17
17
 
18
+ /**
19
+ * Fallback decorator for handling websocket messages whose action
20
+ * does not match any registered @Action decorator.
21
+ */
22
+ export function UnhandledAction() {
23
+ return function (target: any, propertyKey: string) {
24
+ target.constructor._unhandledActionMetadata = {
25
+ key: propertyKey,
26
+ };
27
+ };
28
+ }
29
+
18
30
  /**
19
31
  * Request decorator for handling HTTP requests with path and method routing
20
32
  * @param options Configuration for the HTTP request handler
@@ -98,4 +110,4 @@ export function Guard(guards: GuardFn[]) {
98
110
  }
99
111
  target.constructor['_actionGuards'].set(propertyKey, guards);
100
112
  };
101
- }
113
+ }
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/server.ts CHANGED
@@ -615,7 +615,10 @@ export class Server implements Party.Server {
615
615
  return;
616
616
  }
617
617
 
618
- const sessionExpiryTime = subRoom.constructor.sessionExpiryTime;
618
+ const sessionExpiryTime =
619
+ subRoom.sessionExpiryTime ??
620
+ subRoom.constructor.sessionExpiryTime ??
621
+ 5 * 60 * 1000;
619
622
  await this.garbageCollector({ sessionExpiryTime });
620
623
 
621
624
  // Check room guards
@@ -801,36 +804,50 @@ export class Server implements Party.Server {
801
804
  }
802
805
 
803
806
  const actions = subRoom.constructor["_actionMetadata"];
804
- if (actions) {
805
- const signal = this.getUsersProperty(subRoom);
806
- const { publicId } = sender.state as any;
807
- const user = signal?.()[publicId];
808
- const actionName = actions.get(result.data.action);
809
- if (actionName) {
807
+ const signal = this.getUsersProperty(subRoom);
808
+ const { publicId } = sender.state as any;
809
+ const user = signal?.()[publicId];
810
+ const actionName = actions?.get(result.data.action);
811
+ if (actionName) {
812
+
813
+ // Check all guards if they exist
814
+ const guards = subRoom.constructor['_actionGuards']?.get(actionName.key) || [];
815
+ for (const guard of guards) {
816
+ const isAuthorized = await guard(sender, result.data.value, this.room);
817
+ if (!isAuthorized) {
818
+ return;
819
+ }
820
+ }
810
821
 
811
- // Check all guards if they exist
812
- const guards = subRoom.constructor['_actionGuards']?.get(actionName.key) || [];
813
- for (const guard of guards) {
814
- const isAuthorized = await guard(sender, result.data.value);
815
- if (!isAuthorized) {
816
- return;
817
- }
822
+ // Validate action body if a validation schema is defined
823
+ if (actionName.bodyValidation) {
824
+ const bodyResult = actionName.bodyValidation.safeParse(
825
+ result.data.value
826
+ );
827
+ if (!bodyResult.success) {
828
+ return;
818
829
  }
830
+ }
831
+ // Execute the action
832
+ await awaitReturn(
833
+ subRoom[actionName.key](user, result.data.value, sender)
834
+ );
835
+ return;
836
+ }
819
837
 
820
- // Validate action body if a validation schema is defined
821
- if (actionName.bodyValidation) {
822
- const bodyResult = actionName.bodyValidation.safeParse(
823
- result.data.value
824
- );
825
- if (!bodyResult.success) {
826
- return;
827
- }
838
+ const unhandledAction = subRoom.constructor["_unhandledActionMetadata"];
839
+ if (unhandledAction) {
840
+ const guards = subRoom.constructor['_actionGuards']?.get(unhandledAction.key) || [];
841
+ for (const guard of guards) {
842
+ const isAuthorized = await guard(sender, result.data, this.room);
843
+ if (!isAuthorized) {
844
+ return;
828
845
  }
829
- // Execute the action
830
- await awaitReturn(
831
- subRoom[actionName.key](user, result.data.value, sender)
832
- );
833
846
  }
847
+
848
+ await awaitReturn(
849
+ subRoom[unhandledAction.key](user, result.data, sender)
850
+ );
834
851
  }
835
852
  }
836
853
 
@@ -1275,9 +1292,7 @@ export class Server implements Party.Server {
1275
1292
 
1276
1293
  const url = new URL(req.url);
1277
1294
  const method = req.method;
1278
- let pathname = url.pathname;
1279
-
1280
- pathname = '/' + pathname.split('/').slice(4).join('/');
1295
+ const pathname = this.normalizeRequestPath(url.pathname);
1281
1296
 
1282
1297
  // Check each registered handler
1283
1298
  for (const [routeKey, handler] of requestHandlers.entries()) {
@@ -1311,16 +1326,18 @@ export class Server implements Party.Server {
1311
1326
  if (handler.bodyValidation && ['POST', 'PUT', 'PATCH'].includes(method)) {
1312
1327
  try {
1313
1328
  const contentType = req.headers.get('content-type') || '';
1314
- if (contentType.includes('application/json')) {
1315
- const body = await req.json();
1316
- const validation = handler.bodyValidation.safeParse(body);
1317
- if (!validation.success) {
1318
- return res.badRequest("Invalid request body", {
1319
- details: validation.error
1320
- });
1321
- }
1322
- bodyData = validation.data;
1329
+ if (!contentType.includes('application/json')) {
1330
+ return res.badRequest("Content-Type must be application/json");
1331
+ }
1332
+
1333
+ const body = await req.json();
1334
+ const validation = handler.bodyValidation.safeParse(body);
1335
+ if (!validation.success) {
1336
+ return res.badRequest("Invalid request body", {
1337
+ details: validation.error
1338
+ });
1323
1339
  }
1340
+ bodyData = validation.data;
1324
1341
  } catch (error) {
1325
1342
  return res.badRequest("Failed to parse request body");
1326
1343
  }
@@ -1358,14 +1375,7 @@ export class Server implements Party.Server {
1358
1375
  * @returns {boolean} True if the paths match
1359
1376
  */
1360
1377
  private pathMatches(requestPath: string, handlerPath: string): boolean {
1361
- // Convert handler path pattern to regex
1362
- // Replace :param with named capture groups
1363
- const pathRegexString = handlerPath
1364
- .replace(/\//g, '\\/') // Escape slashes
1365
- .replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
1366
-
1367
- const pathRegex = new RegExp(`^${pathRegexString}`);
1368
- return pathRegex.test(requestPath);
1378
+ return this.pathPatternToRegex(handlerPath).test(requestPath);
1369
1379
  }
1370
1380
 
1371
1381
  /**
@@ -1387,12 +1397,7 @@ export class Server implements Party.Server {
1387
1397
  }
1388
1398
  });
1389
1399
 
1390
- // Extract parameter values from request path
1391
- const pathRegexString = handlerPath
1392
- .replace(/\//g, '\\/') // Escape slashes
1393
- .replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
1394
-
1395
- const pathRegex = new RegExp(`^${pathRegexString}`);
1400
+ const pathRegex = this.pathPatternToRegex(handlerPath);
1396
1401
  const matches = requestPath.match(pathRegex);
1397
1402
 
1398
1403
  if (matches && matches.length > 1) {
@@ -1405,6 +1410,28 @@ export class Server implements Party.Server {
1405
1410
  return params;
1406
1411
  }
1407
1412
 
1413
+ private normalizeRequestPath(pathname: string): string {
1414
+ const parts = pathname.split('/').filter(Boolean);
1415
+ if (parts[0] === 'parties' && parts.length >= 3) {
1416
+ const routePath = parts.slice(3).join('/');
1417
+ return routePath ? `/${routePath}` : '/';
1418
+ }
1419
+
1420
+ return pathname || '/';
1421
+ }
1422
+
1423
+ private pathPatternToRegex(handlerPath: string): RegExp {
1424
+ const segments = handlerPath.split('/').map(segment => {
1425
+ if (segment.startsWith(':')) {
1426
+ return '([^/]+)';
1427
+ }
1428
+
1429
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1430
+ });
1431
+
1432
+ return new RegExp(`^${segments.join('/')}$`);
1433
+ }
1434
+
1408
1435
  /**
1409
1436
  * @method handleShardRequest
1410
1437
  * @private
package/src/world.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { signal } from "@signe/reactive";
2
- import { Room, Action, Guard, Request } from "./decorators";
2
+ import { Room, Guard, Request } from "./decorators";
3
3
  import { sync, id, persist } from "@signe/sync";
4
4
  import { z } from "zod";
5
5
  import * as Party from "./types/party";
6
6
  import { guardManageWorld } from "./world.guard";
7
- import { response } from "./utils";
8
7
  import { RoomInterceptorPacket, RoomOnJoin } from "./interfaces";
9
8
  import { ServerResponse } from "./request/response";
10
9
 
@@ -24,14 +23,8 @@ const RoomConfigSchema = z.object({
24
23
  maxShards: z.number().int().positive().optional(),
25
24
  });
26
25
 
27
- const RegisterShardSchema = z.object({
28
- shardId: z.string(),
29
- roomId: z.string(),
30
- url: z.string().url(),
31
- maxConnections: z.number().int().positive(),
32
- });
33
-
34
26
  const UpdateShardStatsSchema = z.object({
27
+ shardId: z.string(),
35
28
  connections: z.number().int().min(0),
36
29
  status: z.enum(['active', 'maintenance', 'draining']).optional(),
37
30
  });
@@ -139,8 +132,15 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
139
132
  method: 'POST',
140
133
  })
141
134
  @Guard([guardManageWorld])
142
- async registerRoom(req: Party.Request) {
143
- const roomConfig: z.infer<typeof RoomConfigSchema> = await req.json();
135
+ async registerRoom(req: Party.Request, res?: ServerResponse) {
136
+ const roomConfigResult = RoomConfigSchema.safeParse(await req.json());
137
+ if (!roomConfigResult.success) {
138
+ return res?.badRequest("Invalid room configuration", {
139
+ details: roomConfigResult.error
140
+ });
141
+ }
142
+
143
+ const roomConfig = roomConfigResult.data;
144
144
  const roomId = roomConfig.name;
145
145
 
146
146
  if (!this.rooms()[roomId]) {
@@ -178,7 +178,14 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
178
178
  })
179
179
  @Guard([guardManageWorld])
180
180
  async updateShardStats(req: Party.Request, res: ServerResponse) {
181
- const body: { shardId: string; connections: number; status: ShardStatus } = await req.json();
181
+ const bodyResult = UpdateShardStatsSchema.safeParse(await req.json());
182
+ if (!bodyResult.success) {
183
+ return res.badRequest("Invalid shard statistics", {
184
+ details: bodyResult.error
185
+ });
186
+ }
187
+
188
+ const body = bodyResult.data;
182
189
  const { shardId, connections, status } = body;
183
190
  const shard = this.shards()[shardId];
184
191
 
@@ -199,7 +206,14 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
199
206
  })
200
207
  @Guard([guardManageWorld])
201
208
  async scaleRoom(req: Party.Request, res: ServerResponse) {
202
- const data: z.infer<typeof ScaleRoomSchema> = await req.json();
209
+ const dataResult = ScaleRoomSchema.safeParse(await req.json());
210
+ if (!dataResult.success) {
211
+ return res.badRequest("Invalid scale request", {
212
+ details: dataResult.error
213
+ });
214
+ }
215
+
216
+ const data = dataResult.data;
203
217
  const { targetShardCount, shardTemplate, roomId } = data;
204
218
 
205
219
  // Validate room exists
@@ -235,11 +249,6 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
235
249
  })
236
250
  .slice(0, previousShardCount - targetShardCount);
237
251
 
238
- // Remove the selected shards
239
- const shardsToKeep = roomShards.filter(
240
- shard => !shardsToRemove.some(s => s.id === shard.id)
241
- );
242
-
243
252
  // Update shards
244
253
  for (const shard of shardsToRemove) {
245
254
  delete this.shards()[shard.id];
@@ -448,4 +457,4 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
448
457
  this.shards()[shardId] = newShard;
449
458
  return newShard;
450
459
  }
451
- }
460
+ }