@signe/room 1.4.1 → 2.0.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.
@@ -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.1",
3
+ "version": "2.0.0",
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.1"
20
+ "@signe/sync": "2.0.0"
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 } 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, body: any, params: { id: string }) {
101
+ const player = this.players()[params.id];
102
+ if (!player) {
103
+ return new Response(JSON.stringify({ error: "Player not found" }), { status: 404 });
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
+ @RequestGuard([isAuthenticated])
117
+ submitScore(req: Party.Request, body: { playerId: string; score: number }) {
118
+ this.scores.update(scores => [...scores, body]);
119
+ return { 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
+ @RequestGuard([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,167 @@ 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. Configure your `partykit.json` file:
285
+
286
+ ```json
287
+ {
288
+ "$schema": "https://www.partykit.io/schema.json",
289
+ "name": "yourapp",
290
+ "main": "party/server.ts",
291
+ "compatibilityDate": "2025-02-04",
292
+ "parties": {
293
+ "shard": "party/shard.ts", // Shard implementation
294
+ "world": "party/server.ts" // World service implementation
295
+ }
296
+ }
297
+ ```
298
+
299
+ #### Client Connection
300
+
301
+ On the client side, use the `connectionWorld` function to connect to your room through the World service:
302
+
303
+ ```js
304
+ import { connectionWorld } from '@signe/sync/client';
305
+
306
+ // Initialize your room instance
307
+ const room = new YourRoomSchema();
308
+
309
+ // Connect through the World service
310
+ const connection = await connectionWorld({
311
+ worldUrl: 'https://your-app-url.com', // Your application URL
312
+ roomId: 'unique-room-id', // Room identifier
313
+ worldId: 'your-world-id', // Optional, defaults to 'world-default'
314
+ autoCreate: true, // Auto-create room if it doesn't exist
315
+ retryCount: 3, // Number of connection attempts
316
+ retryDelay: 1000, // Delay between retries in ms
317
+ socketOptions: { // Optional PartySocket configuration
318
+ protocols: ['your-protocol']
319
+ }
320
+ }, room);
321
+
322
+ // Listen for events
323
+ connection.on('customEvent', (data) => {
324
+ console.log('Received custom event:', data);
325
+ });
326
+
327
+ // Send events to the room
328
+ connection.emit('increment', { value: 1 });
329
+
330
+ // Close the connection when done
331
+ connection.close();
332
+ ```
333
+
334
+ For connecting to a standard room (not through World service), use the `connectionRoom` function:
335
+
336
+ ```js
337
+ import { connectionRoom } from '@signe/sync/client';
338
+
339
+ // Initialize your room instance
340
+ const room = new YourRoomSchema();
341
+
342
+ // Connect directly to a room
343
+ const connection = await connectionRoom({
344
+ host: window.location.origin,
345
+ room: 'your-room-name',
346
+ party: 'your-party-name', // Optional, defaults to main party
347
+ query: {} // Optional query parameters
348
+ }, room);
349
+
350
+ // For connecting to a World room with authentication
351
+ const worldConnection = await connectionRoom({
352
+ host: window.location.origin,
353
+ room: 'world-default',
354
+ party: 'world',
355
+ query: {
356
+ // Use pre-generated JWT token for authentication
357
+ 'world-auth-token': 'your-jwt-token'
358
+ }
359
+ }, worldRoom);
360
+ ```
361
+
362
+ The `connectionWorld` function:
363
+ 1. Queries the World service to find the optimal shard for the requested room
364
+ 2. Establishes a WebSocket connection to the assigned shard
365
+ 3. Returns a connection object with methods for sending and receiving messages
366
+
367
+ This approach offers several benefits:
368
+ - Automatic load balancing across multiple servers
369
+ - Simplified connection management
370
+ - Built-in retry logic for reliability
371
+ - Room creation on demand
372
+
373
+ ### Packet Interception
374
+
375
+ You can implement the `interceptorPacket` method in your room to inspect and modify packets before they're sent to users:
376
+
377
+ ```ts
378
+ class GameRoom {
379
+ // Intercept packets before they're sent to users
380
+ async interceptorPacket(user: Player, packet: any, conn: Party.Connection) {
381
+ // Modify the packet based on user-specific logic
382
+ if (user.role === 'spectator') {
383
+ delete modifiedPacket.secretData;
384
+ return modifiedPacket;
385
+ }
386
+
387
+ // Return null to prevent the packet from being sent to this user
388
+ if (user.isBlocked) {
389
+ return null;
390
+ }
391
+
392
+ // Return the packet as is or with modifications
393
+ return packet;
394
+ }
395
+ }
396
+ ```
397
+
398
+ The `interceptorPacket` method allows you to:
399
+ - Modify packets on a per-user basis before they're sent
400
+ - Return a modified packet to change what the user receives
401
+ - Return `null` to prevent the packet from being sent to that user
402
+ - Implement user-specific filtering or censoring of data
403
+
179
404
  ### Lifecycle Hooks
180
405
 
181
406
  Rooms provide several lifecycle hooks:
182
407
 
183
408
  ```ts
184
409
  class GameRoom {
185
- async onCreate()
186
410
  async onJoin(player: Player, conn: Connection, ctx: ConnectionContext) {}
187
411
  async onLeave(player: Player, conn: Connection) {}
188
412
  }
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,7 @@
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';
@@ -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
+ }