@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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +570 -692
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
- package/readme.md +55 -6
- package/src/decorators.ts +13 -1
- package/src/jwt.ts +1 -5
- package/src/server.ts +79 -52
- package/src/world.ts +28 -19
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signe/room",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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,
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 =
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|