@signe/room 2.9.3 → 2.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signe/room",
3
- "version": "2.9.3",
3
+ "version": "2.10.0",
4
4
  "description": "PartyKit room primitives with synchronized state, sessions, guards, and HTTP handlers.",
5
5
  "main": "./dist/index.js",
6
6
  "keywords": [
@@ -23,7 +23,7 @@
23
23
  "dset": "^3.1.3",
24
24
  "partysocket": "^1.0.1",
25
25
  "zod": "^3.23.8",
26
- "@signe/sync": "2.9.3"
26
+ "@signe/sync": "2.10.0"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"
package/readme.md CHANGED
@@ -123,7 +123,7 @@ The `@Request` decorator allows you to handle HTTP requests with specific routes
123
123
 
124
124
  ```ts
125
125
  import { z } from "zod";
126
- import { Guard, Room, Request, ServerResponse } from "@signe/room";
126
+ import { Room, Request, RequestGuard, ServerResponse } from "@signe/room";
127
127
 
128
128
  @Room({
129
129
  path: "api"
@@ -169,12 +169,11 @@ class ApiRoom {
169
169
  }
170
170
  ```
171
171
 
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.
172
+ Request handler methods receive these parameters:
173
+ 1. `req`: The original Party.Request object
174
+ 2. `body`: The validated request body (if validation schema was provided)
175
+ 3. `params`: An object containing any path parameters
176
+ 4. `room`: The Party.Room instance
178
177
 
179
178
  You can return:
180
179
  - A Response object for complete control
package/src/jwt.ts CHANGED
@@ -132,7 +132,9 @@ 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
135
136
  const encodedHeader: string = this.base64UrlEncode(this.encoder.encode(JSON.stringify(header)));
137
+ // @ts-expect-error - TS doesn't have a built-in TextEncoder
136
138
  const encodedPayload: string = this.base64UrlEncode(this.encoder.encode(JSON.stringify(fullPayload)));
137
139
 
138
140
  // Create signature base
@@ -172,7 +174,9 @@ export class JWTAuth {
172
174
 
173
175
  // Decode header and payload
174
176
  try {
177
+ // @ts-expect-error - TS doesn't have a built-in TextDecoder
175
178
  const header: JWTHeader = JSON.parse(this.decoder.decode(this.base64UrlDecode(encodedHeader)));
179
+ // @ts-expect-error - TS doesn't have a built-in TextDecoder
176
180
  const payload: JWTPayload = JSON.parse(this.decoder.decode(this.base64UrlDecode(encodedPayload)));
177
181
 
178
182
  // Check algorithm
@@ -210,4 +214,4 @@ export class JWTAuth {
210
214
  throw new Error('Token verification failed: Unknown error');
211
215
  }
212
216
  }
213
- }
217
+ }
package/src/server.ts CHANGED
@@ -615,10 +615,7 @@ export class Server implements Party.Server {
615
615
  return;
616
616
  }
617
617
 
618
- const sessionExpiryTime =
619
- subRoom.sessionExpiryTime ??
620
- subRoom.constructor.sessionExpiryTime ??
621
- 5 * 60 * 1000;
618
+ const sessionExpiryTime = subRoom.constructor.sessionExpiryTime;
622
619
  await this.garbageCollector({ sessionExpiryTime });
623
620
 
624
621
  // Check room guards
@@ -1292,7 +1289,9 @@ export class Server implements Party.Server {
1292
1289
 
1293
1290
  const url = new URL(req.url);
1294
1291
  const method = req.method;
1295
- const pathname = this.normalizeRequestPath(url.pathname);
1292
+ let pathname = url.pathname;
1293
+
1294
+ pathname = '/' + pathname.split('/').slice(4).join('/');
1296
1295
 
1297
1296
  // Check each registered handler
1298
1297
  for (const [routeKey, handler] of requestHandlers.entries()) {
@@ -1326,18 +1325,16 @@ export class Server implements Party.Server {
1326
1325
  if (handler.bodyValidation && ['POST', 'PUT', 'PATCH'].includes(method)) {
1327
1326
  try {
1328
1327
  const contentType = req.headers.get('content-type') || '';
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
- });
1328
+ if (contentType.includes('application/json')) {
1329
+ const body = await req.json();
1330
+ const validation = handler.bodyValidation.safeParse(body);
1331
+ if (!validation.success) {
1332
+ return res.badRequest("Invalid request body", {
1333
+ details: validation.error
1334
+ });
1335
+ }
1336
+ bodyData = validation.data;
1339
1337
  }
1340
- bodyData = validation.data;
1341
1338
  } catch (error) {
1342
1339
  return res.badRequest("Failed to parse request body");
1343
1340
  }
@@ -1375,7 +1372,14 @@ export class Server implements Party.Server {
1375
1372
  * @returns {boolean} True if the paths match
1376
1373
  */
1377
1374
  private pathMatches(requestPath: string, handlerPath: string): boolean {
1378
- return this.pathPatternToRegex(handlerPath).test(requestPath);
1375
+ // Convert handler path pattern to regex
1376
+ // Replace :param with named capture groups
1377
+ const pathRegexString = handlerPath
1378
+ .replace(/\//g, '\\/') // Escape slashes
1379
+ .replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
1380
+
1381
+ const pathRegex = new RegExp(`^${pathRegexString}`);
1382
+ return pathRegex.test(requestPath);
1379
1383
  }
1380
1384
 
1381
1385
  /**
@@ -1397,7 +1401,12 @@ export class Server implements Party.Server {
1397
1401
  }
1398
1402
  });
1399
1403
 
1400
- const pathRegex = this.pathPatternToRegex(handlerPath);
1404
+ // Extract parameter values from request path
1405
+ const pathRegexString = handlerPath
1406
+ .replace(/\//g, '\\/') // Escape slashes
1407
+ .replace(/:([^\/]+)/g, '([^/]+)'); // Convert :params to capture groups
1408
+
1409
+ const pathRegex = new RegExp(`^${pathRegexString}`);
1401
1410
  const matches = requestPath.match(pathRegex);
1402
1411
 
1403
1412
  if (matches && matches.length > 1) {
@@ -1410,28 +1419,6 @@ export class Server implements Party.Server {
1410
1419
  return params;
1411
1420
  }
1412
1421
 
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
-
1435
1422
  /**
1436
1423
  * @method handleShardRequest
1437
1424
  * @private
package/src/world.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { signal } from "@signe/reactive";
2
- import { Room, Guard, Request } from "./decorators";
2
+ import { Room, Action, 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";
7
8
  import { RoomInterceptorPacket, RoomOnJoin } from "./interfaces";
8
9
  import { ServerResponse } from "./request/response";
9
10
 
@@ -23,8 +24,14 @@ const RoomConfigSchema = z.object({
23
24
  maxShards: z.number().int().positive().optional(),
24
25
  });
25
26
 
26
- const UpdateShardStatsSchema = z.object({
27
+ const RegisterShardSchema = z.object({
27
28
  shardId: z.string(),
29
+ roomId: z.string(),
30
+ url: z.string().url(),
31
+ maxConnections: z.number().int().positive(),
32
+ });
33
+
34
+ const UpdateShardStatsSchema = z.object({
28
35
  connections: z.number().int().min(0),
29
36
  status: z.enum(['active', 'maintenance', 'draining']).optional(),
30
37
  });
@@ -72,10 +79,10 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
72
79
  // Synchronized state
73
80
  @sync(RoomConfig) rooms = signal<Record<string, RoomConfig>>({});
74
81
  @sync(ShardInfo) shards = signal<Record<string, ShardInfo>>({});
75
-
82
+
76
83
  // Only persisted state (not synced to clients)
77
84
  @persist() rrCounters = signal<Record<string, number>>({});
78
-
85
+
79
86
  // Configuration
80
87
  defaultShardUrlTemplate = signal("{shardId}");
81
88
  defaultMaxConnectionsPerShard = signal(MAX_PLAYERS_PER_SHARD);
@@ -106,43 +113,36 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
106
113
  }
107
114
  return obj;
108
115
  }
109
-
116
+
110
117
  // Helper methods
111
118
  private cleanupInactiveShards() {
112
119
  const now = Date.now();
113
120
  const timeout = 5 * 60 * 1000; // 5 minutes timeout
114
121
  const shardsValue = this.shards();
115
-
122
+
116
123
  let hasChanges = false;
117
124
  Object.values(shardsValue).forEach(shard => {
118
125
  if (now - shard.lastHeartbeat() > timeout) {
119
126
  delete this.shards()[shard.id];
120
-
127
+
121
128
  hasChanges = true;
122
129
  }
123
130
  });
124
-
131
+
125
132
  // Schedule next cleanup
126
133
  setTimeout(() => this.cleanupInactiveShards(), 60000);
127
134
  }
128
-
135
+
129
136
  // Actions
130
137
  @Request({
131
138
  path: 'register-room',
132
139
  method: 'POST',
133
140
  })
134
141
  @Guard([guardManageWorld])
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;
142
+ async registerRoom(req: Party.Request) {
143
+ const roomConfig: z.infer<typeof RoomConfigSchema> = await req.json();
144
144
  const roomId = roomConfig.name;
145
-
145
+
146
146
  if (!this.rooms()[roomId]) {
147
147
  const newRoom = new RoomConfig();
148
148
  newRoom.id = roomId;
@@ -152,9 +152,9 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
152
152
  newRoom.maxPlayersPerShard.set(roomConfig.maxPlayersPerShard);
153
153
  newRoom.minShards.set(roomConfig.minShards);
154
154
  newRoom.maxShards.set(roomConfig.maxShards);
155
-
155
+
156
156
  this.rooms()[roomId] = newRoom;
157
-
157
+
158
158
  // Ensure minimum shards are created
159
159
  if (roomConfig.minShards > 0) {
160
160
  for (let i = 0; i < roomConfig.minShards; i++) {
@@ -171,62 +171,48 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
171
171
  room.maxShards.set(roomConfig.maxShards);
172
172
  }
173
173
  }
174
-
174
+
175
175
  @Request({
176
176
  path: 'update-shard',
177
177
  method: 'POST',
178
178
  })
179
179
  @Guard([guardManageWorld])
180
180
  async updateShardStats(req: Party.Request, res: ServerResponse) {
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;
181
+ const body: { shardId: string; connections: number; status: ShardStatus } = await req.json();
189
182
  const { shardId, connections, status } = body;
190
183
  const shard = this.shards()[shardId];
191
184
 
192
185
  if (!shard) {
193
186
  return res.notFound(`Shard ${shardId} not found`);
194
187
  }
195
-
188
+
196
189
  shard.currentConnections.set(connections);
197
190
  if (status) {
198
191
  shard.status.set(status);
199
192
  }
200
193
  shard.lastHeartbeat.set(Date.now());
201
194
  }
202
-
195
+
203
196
  @Request({
204
197
  path: 'scale-room',
205
198
  method: 'POST',
206
199
  })
207
200
  @Guard([guardManageWorld])
208
201
  async scaleRoom(req: Party.Request, res: ServerResponse) {
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;
202
+ const data: z.infer<typeof ScaleRoomSchema> = await req.json();
217
203
  const { targetShardCount, shardTemplate, roomId } = data;
218
-
204
+
219
205
  // Validate room exists
220
206
  const room = this.rooms()[roomId];
221
207
  if (!room) {
222
208
  return res.notFound(`Room ${roomId} does not exist`);
223
209
  }
224
-
210
+
225
211
  const roomShards = Object.values(this.shards())
226
212
  .filter(shard => shard.roomId() === roomId);
227
-
213
+
228
214
  const previousShardCount = roomShards.length;
229
-
215
+
230
216
  // Check max shards constraint
231
217
  if (room.maxShards() !== undefined && targetShardCount > room.maxShards()!) {
232
218
  return res.badRequest(`Cannot scale beyond maximum allowed shards (${room.maxShards()})`, {
@@ -234,7 +220,7 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
234
220
  currentShardCount: previousShardCount
235
221
  });
236
222
  }
237
-
223
+
238
224
  // Handle scaling down
239
225
  if (targetShardCount < previousShardCount) {
240
226
  // Find candidates for removal (prioritize draining or low-connection shards)
@@ -243,24 +229,29 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
243
229
  // Prioritize draining status
244
230
  if (a.status() === 'draining' && b.status() !== 'draining') return -1;
245
231
  if (a.status() !== 'draining' && b.status() === 'draining') return 1;
246
-
232
+
247
233
  // Then by connection count (ascending)
248
234
  return a.currentConnections() - b.currentConnections();
249
235
  })
250
236
  .slice(0, previousShardCount - targetShardCount);
251
-
237
+
238
+ // Remove the selected shards
239
+ const shardsToKeep = roomShards.filter(
240
+ shard => !shardsToRemove.some(s => s.id === shard.id)
241
+ );
242
+
252
243
  // Update shards
253
244
  for (const shard of shardsToRemove) {
254
245
  delete this.shards()[shard.id];
255
246
  }
256
-
247
+
257
248
  return;
258
249
  }
259
-
250
+
260
251
  // Handle scaling up
261
252
  if (targetShardCount > previousShardCount) {
262
253
  const newShards = [];
263
-
254
+
264
255
  // Create new shards
265
256
  for (let i = 0; i < targetShardCount - previousShardCount; i++) {
266
257
  const newShard = await this.createShard(
@@ -268,7 +259,7 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
268
259
  shardTemplate?.urlTemplate,
269
260
  shardTemplate?.maxConnections
270
261
  );
271
-
262
+
272
263
  if (newShard) {
273
264
  newShards.push(newShard);
274
265
  }
@@ -284,35 +275,35 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
284
275
  try {
285
276
  // Extract request data
286
277
  let data: { roomId: string; autoCreate?: boolean };
287
-
278
+
288
279
  try {
289
280
  // Handle potential empty body or malformed JSON
290
281
  const body = await req.text();
291
282
  if (!body || body.trim() === '') {
292
283
  return res.badRequest("Request body is empty");
293
284
  }
294
-
285
+
295
286
  data = JSON.parse(body);
296
287
  } catch (parseError) {
297
288
  return res.badRequest("Invalid JSON in request body");
298
289
  }
299
-
290
+
300
291
  // Verify roomId is provided
301
292
  if (!data.roomId) {
302
293
  return res.badRequest("roomId parameter is required");
303
294
  }
304
-
295
+
305
296
  // Determine if auto-creation is enabled (default to true)
306
297
  const autoCreate = data.autoCreate !== undefined ? data.autoCreate : true;
307
-
298
+
308
299
  // Find optimal shard
309
300
  const result = await this.findOptimalShard(data.roomId, autoCreate);
310
-
301
+
311
302
  // Check for errors
312
303
  if ('error' in result) {
313
304
  return res.notFound(result.error);
314
305
  }
315
-
306
+
316
307
  // Return shard information to the client
317
308
  return res.success({
318
309
  success: true,
@@ -324,9 +315,9 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
324
315
  return res.serverError();
325
316
  }
326
317
  }
327
-
318
+
328
319
  private async findOptimalShard(
329
- roomId: string,
320
+ roomId: string,
330
321
  autoCreate: boolean = true
331
322
  ): Promise<{ shardId: string; url: string } | { error: string }> {
332
323
  // Ensure room exists
@@ -343,11 +334,11 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
343
334
  maxShards: undefined
344
335
  })
345
336
  } as Party.Request;
346
-
337
+
347
338
  await this.registerRoom(mockRequest);
348
-
339
+
349
340
  room = this.rooms()[roomId];
350
-
341
+
351
342
  if (!room) {
352
343
  return { error: `Failed to create room ${roomId}` };
353
344
  }
@@ -355,11 +346,11 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
355
346
  return { error: `Room ${roomId} does not exist` };
356
347
  }
357
348
  }
358
-
349
+
359
350
  // Get shards for this room
360
351
  const roomShards = Object.values(this.shards())
361
352
  .filter(shard => shard.roomId() === roomId);
362
-
353
+
363
354
  if (roomShards.length === 0) {
364
355
  if (autoCreate) {
365
356
  // Auto-create a shard
@@ -376,51 +367,51 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
376
367
  return { error: `No shards available for room ${roomId}` };
377
368
  }
378
369
  }
379
-
370
+
380
371
  // Get active shards
381
372
  const activeShards = roomShards
382
373
  .filter(shard => shard && shard.status() === 'active');
383
-
374
+
384
375
  if (activeShards.length === 0) {
385
376
  return { error: `No active shards available for room ${roomId}` };
386
377
  }
387
-
378
+
388
379
  // Apply balancing strategy
389
380
  const balancingStrategy = room.balancingStrategy();
390
381
  let selectedShard: ShardInfo;
391
-
382
+
392
383
  switch (balancingStrategy) {
393
384
  case 'least-connections':
394
385
  // Choose shard with fewest connections
395
386
  selectedShard = activeShards.reduce(
396
- (min, shard) =>
387
+ (min, shard) =>
397
388
  shard.currentConnections() < min.currentConnections() ? shard : min,
398
389
  activeShards[0]
399
390
  );
400
391
  break;
401
-
392
+
402
393
  case 'random':
403
394
  // Choose random shard
404
395
  selectedShard = activeShards[Math.floor(Math.random() * activeShards.length)];
405
396
  break;
406
-
397
+
407
398
  case 'round-robin':
408
399
  default:
409
400
  // Round-robin selection
410
401
  const counter = this.rrCounters()[roomId] || 0;
411
402
  const nextCounter = (counter + 1) % activeShards.length;
412
403
  this.rrCounters()[roomId] = nextCounter;
413
-
404
+
414
405
  selectedShard = activeShards[counter];
415
406
  break;
416
407
  }
417
-
408
+
418
409
  return {
419
410
  shardId: selectedShard.id,
420
411
  url: selectedShard.url()
421
412
  };
422
413
  }
423
-
414
+
424
415
  // Private methods
425
416
  private async createShard(
426
417
  roomId: string,
@@ -432,17 +423,17 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
432
423
  console.error(`Cannot create shard for non-existent room: ${roomId}`);
433
424
  return null;
434
425
  }
435
-
426
+
436
427
  // Generate shard ID
437
428
  const shardId = `${roomId}:${Date.now()}-${Math.floor(Math.random() * 10000)}`;
438
-
429
+
439
430
  // Generate URL from template
440
431
  const template = urlTemplate || this.defaultShardUrlTemplate();
441
432
  const url = template.replace('{shardId}', shardId).replace('{roomId}', roomId);
442
433
 
443
434
  // Set max connections
444
435
  const max = maxConnections || room.maxPlayersPerShard();
445
-
436
+
446
437
  // Create the shard
447
438
  const newShard = new ShardInfo();
448
439
  newShard.id = shardId;
@@ -452,7 +443,7 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
452
443
  newShard.currentConnections.set(0);
453
444
  newShard.status.set("active");
454
445
  newShard.lastHeartbeat.set(Date.now());
455
-
446
+
456
447
  // Update shards collection
457
448
  this.shards()[shardId] = newShard;
458
449
  return newShard;