@signe/room 1.4.2 → 2.0.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.
@@ -0,0 +1,158 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { connectionWorld } from '../../../../../sync/src/client';
3
+ import { RoomSchema } from "../../shared/room.schema";
4
+ import { effect } from "@signe/reactive";
5
+
6
+ export default function Room() {
7
+ const [isConnected, setIsConnected] = useState(false);
8
+ const [isConnecting, setIsConnecting] = useState(false);
9
+ const [error, setError] = useState<string | null>(null);
10
+ const [roomId, setRoomId] = useState("quiz");
11
+ const [count, setCount] = useState(0);
12
+ const socketRef = useRef<any>(null);
13
+ const roomRef = useRef<any>(null);
14
+
15
+ const connectToRoom = async () => {
16
+ setIsConnecting(true);
17
+ setError(null);
18
+
19
+ try {
20
+ // Initialize room schema
21
+ roomRef.current = new RoomSchema();
22
+
23
+ // Connect to the room through the World service with auto-creation enabled
24
+ socketRef.current = await connectionWorld({
25
+ worldUrl: 'http://localhost:1999',
26
+ roomId: roomId,
27
+ autoCreate: true // Enable auto-creation of room and shards
28
+ }, roomRef.current);
29
+
30
+ // Listen for disconnection events
31
+ socketRef.current.on('disconnect', () => {
32
+ setIsConnected(false);
33
+ setError('Disconnected from server');
34
+ });
35
+
36
+ effect(() => {
37
+ if (roomRef.current) {
38
+ setCount(roomRef.current.count());
39
+ }
40
+ });
41
+
42
+ setIsConnected(true);
43
+ } catch (err) {
44
+ console.error('Connection error:', err);
45
+ setError(`Failed to connect to the room: ${err instanceof Error ? err.message : String(err)}`);
46
+ } finally {
47
+ setIsConnecting(false);
48
+ }
49
+ };
50
+
51
+ const disconnectFromRoom = () => {
52
+ if (socketRef.current) {
53
+ socketRef.current.close();
54
+ socketRef.current = null;
55
+ }
56
+ roomRef.current = null;
57
+ setIsConnected(false);
58
+ };
59
+
60
+ // Clean up on component unmount
61
+ useEffect(() => {
62
+ return () => {
63
+ if (socketRef.current) {
64
+ socketRef.current.close();
65
+ }
66
+ };
67
+ }, []);
68
+
69
+ // Styles
70
+ const buttonStyles = {
71
+ backgroundColor: isConnected ? "#f43f5e" : "#2563eb",
72
+ borderRadius: "9999px",
73
+ border: "none",
74
+ color: "white",
75
+ fontSize: "0.95rem",
76
+ cursor: "pointer",
77
+ padding: "1rem 3rem",
78
+ margin: "1rem 0rem",
79
+ disabled: isConnecting
80
+ };
81
+
82
+ const inputStyles = {
83
+ padding: "0.75rem 1rem",
84
+ borderRadius: "0.5rem",
85
+ border: "1px solid #ccc",
86
+ fontSize: "0.95rem",
87
+ width: "100%",
88
+ maxWidth: "300px",
89
+ margin: "0.5rem 0"
90
+ };
91
+
92
+ const containerStyles = {
93
+ display: "flex",
94
+ flexDirection: "column" as "column",
95
+ alignItems: "center",
96
+ justifyContent: "center",
97
+ padding: "2rem",
98
+ gap: "1rem"
99
+ };
100
+
101
+ return (
102
+ <div style={containerStyles}>
103
+ <h1>Room Connection</h1>
104
+
105
+ {error && (
106
+ <div style={{ color: "red", margin: "1rem 0" }}>
107
+ {error}
108
+ </div>
109
+ )}
110
+
111
+ {!isConnected ? (
112
+ <>
113
+ <div style={{ marginBottom: "1rem", width: "100%", maxWidth: "300px" }}>
114
+ <label htmlFor="roomId" style={{ display: "block", marginBottom: "0.5rem" }}>
115
+ Room ID:
116
+ </label>
117
+ <input
118
+ id="roomId"
119
+ type="text"
120
+ value={roomId}
121
+ onChange={(e) => setRoomId(e.target.value)}
122
+ style={inputStyles}
123
+ placeholder="Enter room ID"
124
+ disabled={isConnecting}
125
+ />
126
+ </div>
127
+
128
+ <button
129
+ style={buttonStyles}
130
+ onClick={connectToRoom}
131
+ disabled={isConnecting || !roomId.trim()}
132
+ >
133
+ {isConnecting ? "Connecting..." : "Connect to Room"}
134
+ </button>
135
+ </>
136
+ ) : (
137
+ <>
138
+ <div style={{ marginBottom: "1rem" }}>
139
+ <span style={{ fontWeight: "bold" }}>Connected to room: </span>
140
+ <span>{roomId}</span>
141
+ </div>
142
+
143
+ <div style={{ marginBottom: "2rem" }}>
144
+ <span>Count: {count}</span>
145
+ <button className="btn btn-primary" onClick={() => socketRef.current.emit('increment')}>Increment</button>
146
+ </div>
147
+
148
+ <button
149
+ style={buttonStyles}
150
+ onClick={disconnectFromRoom}
151
+ >
152
+ Leave Room
153
+ </button>
154
+ </>
155
+ )}
156
+ </div>
157
+ );
158
+ }
@@ -1,9 +1,10 @@
1
- import { Server } from '../../../src';
1
+ import { Server, WorldRoom } from '../../../src';
2
2
  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
6
  rooms = [
7
- GameRoom
7
+ GameRoom ,
8
+ WorldRoom
8
9
  ]
9
10
  }
@@ -0,0 +1,5 @@
1
+ import { Shard } from '../../../src';
2
+
3
+ export default class MyShard extends Shard {
4
+
5
+ }
@@ -2,7 +2,11 @@
2
2
  "$schema": "https://www.partykit.io/schema.json",
3
3
  "name": "signe",
4
4
  "main": "party/server.ts",
5
- "compatibilityDate": "2024-06-09",
5
+ "compatibilityDate": "2025-02-04",
6
+ "parties": {
7
+ "shard": "party/shard.ts",
8
+ "world": "party/server.ts"
9
+ },
6
10
  "serve": {
7
11
  "path": "public",
8
12
  "build": "app/client.tsx"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signe/room",
3
- "version": "1.4.2",
3
+ "version": "2.0.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": "1.4.2"
20
+ "@signe/sync": "2.0.1"
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
package/readme.md CHANGED
@@ -13,6 +13,7 @@ npm install @signe/room @signe/reactive @signe/sync
13
13
  - 🔄 Automatic state synchronization across clients
14
14
  - 👥 Built-in user management with customizable player classes
15
15
  - 🎮 Action-based message handling with type safety
16
+ - 🌐 HTTP request routing with path parameters
16
17
  - 🔐 Flexible authentication and authorization system
17
18
  - 🛡️ Guard system for room and action-level security
18
19
  - 🎯 Full TypeScript support
@@ -58,7 +59,7 @@ export default class GameServer extends Server {
58
59
  }
59
60
  ```
60
61
 
61
- ## Action
62
+ ## Action
62
63
 
63
64
  An action is a function that is called when a client sends a message to the server.
64
65
 
@@ -68,6 +69,69 @@ Function have to be decorated with the `@Action` decorator and have 3 parameter
68
69
  - The second parameter is the value of the action
69
70
  - The third parameter is the Party.Connection instance
70
71
 
72
+ ## HTTP Request Handling
73
+
74
+ The `@Request` decorator allows you to handle HTTP requests with specific routes and methods:
75
+
76
+ ```ts
77
+ import { z } from "zod";
78
+ import { Room, Request, RequestGuard, ServerResponse } from "@signe/room";
79
+
80
+ @Room({
81
+ path: "api"
82
+ })
83
+ class ApiRoom {
84
+ @sync() gameState = signal("waiting");
85
+ @users(Player) players = signal({});
86
+ @sync() scores = signal([]);
87
+
88
+ // Handle GET requests
89
+ @Request({ path: "/status" })
90
+ getStatus(req: Party.Request) {
91
+ return {
92
+ status: "online",
93
+ players: Object.keys(this.players()).length,
94
+ gameState: this.gameState(),
95
+ };
96
+ }
97
+
98
+ // Handle requests with path parameters
99
+ @Request({ path: "/players/:id" })
100
+ getPlayer(req: Party.Request, res: ServerResponse) {
101
+ const player = this.players()[req.params.id];
102
+ if (!player) {
103
+ return res.notFound("Player not found");
104
+ }
105
+ return player;
106
+ }
107
+
108
+ // Handle POST requests with body validation
109
+ @Request(
110
+ { path: "/scores", method: "POST" },
111
+ z.object({
112
+ playerId: z.string(),
113
+ score: z.number().min(0)
114
+ })
115
+ )
116
+ @Guard([isAuthenticated])
117
+ submitScore(req: Party.Request, res: ServerResponse) {
118
+ this.scores.update(scores => [...scores, req.data]);
119
+ return res.success({ success: true });
120
+ }
121
+ }
122
+ ```
123
+
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
129
+
130
+ You can return:
131
+ - A Response object for complete control
132
+ - An object that will be serialized as JSON
133
+ - A string that will be returned as text/plain
134
+
71
135
  ## Advanced Features
72
136
 
73
137
  ### Room Configuration
@@ -111,6 +175,12 @@ class AdminRoom {
111
175
  async deleteUser(admin: Player, userId: string) {
112
176
  // Only authenticated admins can execute this
113
177
  }
178
+
179
+ @Request({ path: "/admin/users", method: "DELETE" })
180
+ @Guard([isAdmin]) // Applied only to this request handler
181
+ async deleteUserViaHttp(req: Party.Request) {
182
+ // Only authenticated admins can access this endpoint
183
+ }
114
184
  }
115
185
  ```
116
186
 
@@ -176,13 +246,175 @@ class GameRoom {
176
246
  }
177
247
  ```
178
248
 
249
+ ### Connecting to World Service
250
+
251
+ The World Service provides optimal room and shard assignment for distributed applications. It handles load balancing and allows clients to connect to the most appropriate server.
252
+
253
+ #### Environment Variables
254
+
255
+ To use the Signe room system, you need to configure two essential environment variables:
256
+
257
+ ```env
258
+ # Required for JWT authentication
259
+ AUTH_JWT_SECRET=a-string-secret-at-least-256-bits-long
260
+
261
+ # Required for secure communication between shards
262
+ SHARD_SECRET=your_shard_secret
263
+ ```
264
+
265
+ These secrets should be strong, unique values and kept secure.
266
+
267
+ #### Server Configuration
268
+
269
+ To use the World service, you need to:
270
+
271
+ 1. Add `WorldRoom` to your server:
272
+
273
+ ```ts
274
+ import { Server, WorldRoom } from '@signe/room';
275
+
276
+ export default class MainServer extends Server {
277
+ rooms = [
278
+ GameRoom,
279
+ WorldRoom // Add WorldRoom to enable World service
280
+ ]
281
+ }
282
+ ```
283
+
284
+ 2. Add `Shard` to your server in `party/shard.ts`:
285
+
286
+ ```ts
287
+ import { Shard } from '@signe/room';
288
+
289
+ export default class ShardServer extends Shard {}
290
+ ```
291
+
292
+ 3. Configure your `partykit.json` file:
293
+
294
+ ```json
295
+ {
296
+ "$schema": "https://www.partykit.io/schema.json",
297
+ "name": "yourapp",
298
+ "main": "party/server.ts",
299
+ "compatibilityDate": "2025-02-04",
300
+ "parties": {
301
+ "shard": "party/shard.ts", // Shard implementation
302
+ "world": "party/server.ts" // World service implementation
303
+ }
304
+ }
305
+ ```
306
+
307
+ #### Client Connection
308
+
309
+ On the client side, use the `connectionWorld` function to connect to your room through the World service:
310
+
311
+ ```js
312
+ import { connectionWorld } from '@signe/sync/client';
313
+
314
+ // Initialize your room instance
315
+ const room = new YourRoomSchema();
316
+
317
+ // Connect through the World service
318
+ const connection = await connectionWorld({
319
+ worldUrl: 'https://your-app-url.com', // Your application URL
320
+ roomId: 'unique-room-id', // Room identifier
321
+ worldId: 'your-world-id', // Optional, defaults to 'world-default'
322
+ autoCreate: true, // Auto-create room if it doesn't exist
323
+ retryCount: 3, // Number of connection attempts
324
+ retryDelay: 1000, // Delay between retries in ms
325
+ socketOptions: { // Optional PartySocket configuration
326
+ protocols: ['your-protocol']
327
+ }
328
+ }, room);
329
+
330
+ // Listen for events
331
+ connection.on('customEvent', (data) => {
332
+ console.log('Received custom event:', data);
333
+ });
334
+
335
+ // Send events to the room
336
+ connection.emit('increment', { value: 1 });
337
+
338
+ // Close the connection when done
339
+ connection.close();
340
+ ```
341
+
342
+ For connecting to a standard room (not through World service), use the `connectionRoom` function:
343
+
344
+ ```js
345
+ import { connectionRoom } from '@signe/sync/client';
346
+
347
+ // Initialize your room instance
348
+ const room = new YourRoomSchema();
349
+
350
+ // Connect directly to a room
351
+ const connection = await connectionRoom({
352
+ host: window.location.origin,
353
+ room: 'your-room-name',
354
+ party: 'your-party-name', // Optional, defaults to main party
355
+ query: {} // Optional query parameters
356
+ }, room);
357
+
358
+ // For connecting to a World room with authentication
359
+ const worldConnection = await connectionRoom({
360
+ host: window.location.origin,
361
+ room: 'world-default',
362
+ party: 'world',
363
+ query: {
364
+ // Use pre-generated JWT token for authentication
365
+ 'world-auth-token': 'your-jwt-token'
366
+ }
367
+ }, worldRoom);
368
+ ```
369
+
370
+ The `connectionWorld` function:
371
+ 1. Queries the World service to find the optimal shard for the requested room
372
+ 2. Establishes a WebSocket connection to the assigned shard
373
+ 3. Returns a connection object with methods for sending and receiving messages
374
+
375
+ This approach offers several benefits:
376
+ - Automatic load balancing across multiple servers
377
+ - Simplified connection management
378
+ - Built-in retry logic for reliability
379
+ - Room creation on demand
380
+
381
+ ### Packet Interception
382
+
383
+ You can implement the `interceptorPacket` method in your room to inspect and modify packets before they're sent to users:
384
+
385
+ ```ts
386
+ class GameRoom {
387
+ // Intercept packets before they're sent to users
388
+ async interceptorPacket(user: Player, packet: any, conn: Party.Connection) {
389
+ // Modify the packet based on user-specific logic
390
+ if (user.role === 'spectator') {
391
+ delete modifiedPacket.secretData;
392
+ return modifiedPacket;
393
+ }
394
+
395
+ // Return null to prevent the packet from being sent to this user
396
+ if (user.isBlocked) {
397
+ return null;
398
+ }
399
+
400
+ // Return the packet as is or with modifications
401
+ return packet;
402
+ }
403
+ }
404
+ ```
405
+
406
+ The `interceptorPacket` method allows you to:
407
+ - Modify packets on a per-user basis before they're sent
408
+ - Return a modified packet to change what the user receives
409
+ - Return `null` to prevent the packet from being sent to that user
410
+ - Implement user-specific filtering or censoring of data
411
+
179
412
  ### Lifecycle Hooks
180
413
 
181
414
  Rooms provide several lifecycle hooks:
182
415
 
183
416
  ```ts
184
417
  class GameRoom {
185
- async onCreate()
186
418
  async onJoin(player: Player, conn: Connection, ctx: ConnectionContext) {}
187
419
  async onLeave(player: Player, conn: Connection) {}
188
420
  }
package/src/decorators.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type * as Party from "./types/party"
2
2
  import type { z } from "zod"
3
- type GuardFn = (sender: Party.Connection, value: any) => boolean | Promise<boolean>;
4
- type RoomGuardFn = (conn: Party.Connection, ctx: Party.ConnectionContext) => boolean | Promise<boolean>;
3
+ type GuardFn = (sender: Party.Connection, value: any | Party.Request, room: Party.Room) => boolean | Promise<boolean | Response>;
4
+ type RoomGuardFn = (conn: Party.Connection, ctx: Party.ConnectionContext, room: Party.Room) => boolean | Promise<boolean | Response>;
5
5
 
6
6
  export function Action(name: string, bodyValidation?: z.ZodSchema) {
7
7
  return function (target: any, propertyKey: string) {
@@ -15,6 +15,38 @@ export function Action(name: string, bodyValidation?: z.ZodSchema) {
15
15
  };
16
16
  }
17
17
 
18
+ /**
19
+ * Request decorator for handling HTTP requests with path and method routing
20
+ * @param options Configuration for the HTTP request handler
21
+ * @param bodyValidation Optional Zod schema for request body validation
22
+ */
23
+ export interface RequestOptions {
24
+ path: string;
25
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
26
+ }
27
+
28
+ export function Request(options: RequestOptions, bodyValidation?: z.ZodSchema) {
29
+ return function (target: any, propertyKey: string) {
30
+ if (!target.constructor._requestMetadata) {
31
+ target.constructor._requestMetadata = new Map();
32
+ }
33
+
34
+ // Format the path to ensure it starts with a slash
35
+ const path = options.path.startsWith('/') ? options.path : `/${options.path}`;
36
+ const method = options.method || 'GET';
37
+
38
+ // Create a unique key for this route using method and path
39
+ const routeKey = `${method}:${path}`;
40
+
41
+ target.constructor._requestMetadata.set(routeKey, {
42
+ key: propertyKey,
43
+ path,
44
+ method,
45
+ bodyValidation,
46
+ });
47
+ };
48
+ }
49
+
18
50
  export interface RoomOptions {
19
51
  path: string;
20
52
  maxUsers?: number;
package/src/index.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export * from './decorators';
2
2
  export { ClientIo, MockConnection, ServerIo } from './mock';
3
3
  export { Server } from './server';
4
- export * from './testing';
4
+ export * from './testing';
5
+ export * from './shard';
6
+ export * from './world';
7
+ export * from './interfaces';
8
+ export * from './request/response';
@@ -0,0 +1,13 @@
1
+ import * as Party from "./types/party";
2
+
3
+ export interface RoomInterceptorPacket {
4
+ interceptorPacket(user: any, obj: any, conn: Party.Connection): Promise<any> | null | any;
5
+ }
6
+
7
+ export interface RoomOnJoin {
8
+ onJoin(user: any, conn: Party.Connection, ctx: Party.ConnectionContext): Promise<any> | null | any;
9
+ }
10
+
11
+ export interface RoomOnLeave {
12
+ onLeave(user: any, conn: Party.Connection, ctx: Party.ConnectionContext): Promise<any> | null | any;
13
+ }