@signe/room 2.9.4 → 3.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/chunk-EUXUH3YW.js +15 -0
  3. package/dist/chunk-EUXUH3YW.js.map +1 -0
  4. package/dist/cloudflare/index.d.ts +71 -0
  5. package/dist/cloudflare/index.js +320 -0
  6. package/dist/cloudflare/index.js.map +1 -0
  7. package/dist/index.d.ts +65 -188
  8. package/dist/index.js +742 -146
  9. package/dist/index.js.map +1 -1
  10. package/dist/node/index.d.ts +164 -0
  11. package/dist/node/index.js +786 -0
  12. package/dist/node/index.js.map +1 -0
  13. package/dist/party-dNs-hqkq.d.ts +175 -0
  14. package/examples/cloudflare/README.md +62 -0
  15. package/examples/cloudflare/node_modules/.bin/tsc +17 -0
  16. package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
  17. package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
  18. package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
  19. package/examples/cloudflare/package.json +24 -0
  20. package/examples/cloudflare/public/index.html +443 -0
  21. package/examples/cloudflare/src/index.ts +28 -0
  22. package/examples/cloudflare/src/room.ts +44 -0
  23. package/examples/cloudflare/tsconfig.json +10 -0
  24. package/examples/cloudflare/wrangler.jsonc +25 -0
  25. package/examples/node/README.md +57 -0
  26. package/examples/node/node_modules/.bin/tsc +17 -0
  27. package/examples/node/node_modules/.bin/tsserver +17 -0
  28. package/examples/node/node_modules/.bin/tsx +17 -0
  29. package/examples/node/package.json +23 -0
  30. package/examples/node/public/index.html +443 -0
  31. package/examples/node/room.ts +44 -0
  32. package/examples/node/server.sqlite.ts +52 -0
  33. package/examples/node/server.ts +51 -0
  34. package/examples/node/tsconfig.json +10 -0
  35. package/examples/node-game/README.md +66 -0
  36. package/examples/node-game/package.json +23 -0
  37. package/examples/node-game/public/index.html +705 -0
  38. package/examples/node-game/room.ts +145 -0
  39. package/examples/node-game/server.sqlite.ts +54 -0
  40. package/examples/node-game/server.ts +53 -0
  41. package/examples/node-game/tsconfig.json +10 -0
  42. package/examples/node-shard/README.md +32 -0
  43. package/examples/node-shard/dev.ts +39 -0
  44. package/examples/node-shard/package.json +24 -0
  45. package/examples/node-shard/public/index.html +777 -0
  46. package/examples/node-shard/room-server.ts +68 -0
  47. package/examples/node-shard/room.ts +105 -0
  48. package/examples/node-shard/shared.ts +6 -0
  49. package/examples/node-shard/tsconfig.json +14 -0
  50. package/examples/node-shard/world-server.ts +169 -0
  51. package/package.json +14 -5
  52. package/readme.md +377 -11
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/mock.ts +29 -7
  55. package/src/node/index.ts +1112 -0
  56. package/src/server.ts +626 -90
  57. package/src/session.guard.ts +6 -2
  58. package/src/shard.ts +91 -23
  59. package/src/storage.ts +29 -5
  60. package/src/testing.ts +4 -3
  61. package/src/types/party.ts +4 -1
  62. package/src/world.guard.ts +23 -4
  63. package/src/world.ts +170 -79
  64. package/examples/game/.vscode/launch.json +0 -11
  65. package/examples/game/.vscode/settings.json +0 -11
  66. package/examples/game/README.md +0 -40
  67. package/examples/game/app/client.tsx +0 -15
  68. package/examples/game/app/components/Admin.tsx +0 -1089
  69. package/examples/game/app/components/Room.tsx +0 -162
  70. package/examples/game/app/styles.css +0 -31
  71. package/examples/game/package-lock.json +0 -225
  72. package/examples/game/package.json +0 -20
  73. package/examples/game/party/game.room.ts +0 -32
  74. package/examples/game/party/server.ts +0 -10
  75. package/examples/game/party/shard.ts +0 -5
  76. package/examples/game/partykit.json +0 -14
  77. package/examples/game/public/favicon.ico +0 -0
  78. package/examples/game/public/index.html +0 -27
  79. package/examples/game/public/normalize.css +0 -351
  80. package/examples/game/shared/room.schema.ts +0 -14
  81. package/examples/game/tsconfig.json +0 -109
package/readme.md CHANGED
@@ -104,7 +104,10 @@ class GameRoom {
104
104
  message: { action: string; value: unknown },
105
105
  conn: Party.Connection
106
106
  ) {
107
- console.warn("Unhandled action", message.action, message.value, conn.id);
107
+ console.warn("Unhandled action", message.action, message.value, {
108
+ connectionId: conn.id,
109
+ sessionId: conn.sessionId,
110
+ });
108
111
  }
109
112
  }
110
113
  ```
@@ -123,7 +126,7 @@ The `@Request` decorator allows you to handle HTTP requests with specific routes
123
126
 
124
127
  ```ts
125
128
  import { z } from "zod";
126
- import { Guard, Room, Request, ServerResponse } from "@signe/room";
129
+ import { Room, Request, RequestGuard, ServerResponse } from "@signe/room";
127
130
 
128
131
  @Room({
129
132
  path: "api"
@@ -169,12 +172,11 @@ class ApiRoom {
169
172
  }
170
173
  ```
171
174
 
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.
175
+ Request handler methods receive these parameters:
176
+ 1. `req`: The original Party.Request object
177
+ 2. `body`: The validated request body (if validation schema was provided)
178
+ 3. `params`: An object containing any path parameters
179
+ 4. `room`: The Party.Room instance
178
180
 
179
181
  You can return:
180
182
  - A Response object for complete control
@@ -183,10 +185,57 @@ You can return:
183
185
 
184
186
  ## Advanced Features
185
187
 
188
+ ### User Sessions and Reconnects
189
+
190
+ Rooms use the WebSocket `id` query parameter as the private session id
191
+ (`privateId`). Each active WebSocket still receives its own unique
192
+ `conn.id`; the stable session id is available as `conn.sessionId`. The
193
+ corresponding `publicId` is the key used in `@users()` collections. Query
194
+ parameters such as a display name are user data only; they do not identify the
195
+ session.
196
+
197
+ Identifier summary:
198
+
199
+ | Identifier | Meaning | Stability |
200
+ | --- | --- | --- |
201
+ | `conn.id` | Unique WebSocket connection id | New for every active socket |
202
+ | `conn.sessionId` | Private session id from the WebSocket `id` query parameter | Stable across reconnects and shared by tabs using the same `id` |
203
+ | `publicId` | User id stored in `@users()` collections | Stable for the restored session |
204
+
205
+ Use `conn.id` when you need to address or exclude one physical WebSocket, for
206
+ example `room.broadcast(message, [conn.id])`. Use `conn.sessionId` when you
207
+ need to read, restore, transfer, or log the private user session.
208
+
209
+ Pass a stable `id` when connecting if a browser refresh or reconnect should
210
+ restore the same user. If `id` is omitted, each connection creates a new
211
+ session and therefore a new user entry. To implement logout, remove or rotate
212
+ the stored id before reconnecting.
213
+
214
+ Multiple active WebSockets can use the same session id. They share the same
215
+ `publicId`, receive broadcasts independently, and the user is marked offline
216
+ only after the last connection for that session closes.
217
+
218
+ ```ts
219
+ import { connectionRoom } from "@signe/sync/client";
220
+
221
+ const sessionId =
222
+ localStorage.getItem("room-session-id") ?? crypto.randomUUID();
223
+
224
+ localStorage.setItem("room-session-id", sessionId);
225
+
226
+ await connectionRoom({
227
+ host: window.location.origin,
228
+ room: "your-room-name",
229
+ party: "main",
230
+ id: sessionId,
231
+ }, roomInstance);
232
+ ```
233
+
186
234
  ### Session Transfer
187
235
 
188
236
  You can transfer a user's session from one room to another using `$sessionTransfer`.
189
- This preserves the same session id (privateId) across rooms.
237
+ This is an advanced use of the same session mechanism and preserves the same
238
+ private session id (`privateId`) across rooms.
190
239
 
191
240
  Server-side (inside a room or action):
192
241
 
@@ -436,6 +485,8 @@ class GameRoom {
436
485
 
437
486
  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.
438
487
 
488
+ Use this setup when one logical room may need to accept clients through one or more shard parties. A `WorldRoom` keeps the room and shard registry, while each `Shard` proxies client WebSocket and HTTP traffic to the main room server.
489
+
439
490
  #### Environment Variables
440
491
 
441
492
  To use the Signe room system, you need to configure two essential environment variables:
@@ -448,7 +499,7 @@ AUTH_JWT_SECRET=a-string-secret-at-least-256-bits-long
448
499
  SHARD_SECRET=your_shard_secret
449
500
  ```
450
501
 
451
- These secrets should be strong, unique values and kept secure.
502
+ These secrets should be strong, unique values and kept secure. `SHARD_SECRET` is used for shard-to-world stats updates and shard-to-main-server traffic. A request or WebSocket connection that claims to come from a shard must include this secret.
452
503
 
453
504
  #### Server Configuration
454
505
 
@@ -475,6 +526,34 @@ import { Shard } from '@signe/room';
475
526
  export default class ShardServer extends Shard {}
476
527
  ```
477
528
 
529
+ By default, a shard belongs to `world-default`. For a multi-world deployment, the shard can resolve its world from the shard id generated by `WorldRoom`, from constructor options, or from environment variables:
530
+
531
+ ```ts
532
+ import { Shard, type Party } from '@signe/room';
533
+
534
+ export default class EuShardServer extends Shard {
535
+ constructor(room: Party.Room) {
536
+ super(room, {
537
+ worldId: 'world-eu'
538
+ });
539
+ }
540
+ }
541
+ ```
542
+
543
+ If you let `WorldRoom` create shard metadata, shard ids use this format:
544
+
545
+ ```txt
546
+ {roomId}:{worldId}:{uniqueShardId}
547
+ ```
548
+
549
+ For example:
550
+
551
+ ```txt
552
+ match-123:world-eu:1710000000000-4821
553
+ ```
554
+
555
+ The `Shard` class can read `world-eu` from that id and report stats back to the matching world.
556
+
478
557
  3. Configure your `partykit.json` file:
479
558
 
480
559
  ```json
@@ -490,6 +569,85 @@ export default class ShardServer extends Shard {}
490
569
  }
491
570
  ```
492
571
 
572
+ #### Multi-World Setup
573
+
574
+ `WorldRoom` uses the dynamic path `world-{worldId}`. This means these are separate world instances:
575
+
576
+ ```txt
577
+ /parties/world/world-default
578
+ /parties/world/world-eu
579
+ /parties/world/world-us
580
+ ```
581
+
582
+ Each world owns its own room registry, shard registry, load-balancing counters, shard heartbeats, and inactive shard cleanup. A shard should report to exactly one world.
583
+
584
+ When a client connects through a specific world, pass the same `worldId` to `connectionWorld()`:
585
+
586
+ ```js
587
+ const euConnection = await connectionWorld({
588
+ host: 'https://your-app-url.com',
589
+ room: 'match-123',
590
+ worldId: 'world-eu',
591
+ autoCreate: true
592
+ }, room);
593
+ ```
594
+
595
+ For an admin connection to a world room, use the world id as the room id:
596
+
597
+ ```js
598
+ const worldConnection = await connectionRoom({
599
+ host: window.location.origin,
600
+ room: 'world-eu',
601
+ party: 'world',
602
+ query: {
603
+ 'world-auth-token': 'your-jwt-token'
604
+ }
605
+ }, worldRoom);
606
+ ```
607
+
608
+ #### World Admin Authorization
609
+
610
+ World management endpoints and world-room WebSocket connections require a JWT signed with `AUTH_JWT_SECRET`. The token must include a `worlds` claim listing the world ids that the operator can access:
611
+
612
+ ```json
613
+ {
614
+ "sub": "operator-1",
615
+ "worlds": ["world-default", "world-eu"]
616
+ }
617
+ ```
618
+
619
+ Use `["*"]` for a global operator:
620
+
621
+ ```json
622
+ {
623
+ "sub": "admin",
624
+ "worlds": ["*"]
625
+ }
626
+ ```
627
+
628
+ Tokens without a `worlds` claim, or without the current world id in that claim, are rejected even when the JWT signature is valid. Admin clients can pass the token either with an `Authorization: Bearer <token>` header or with the `world-auth-token` query parameter:
629
+
630
+ ```js
631
+ await fetch('/parties/world/world-eu/register-room', {
632
+ method: 'POST',
633
+ headers: {
634
+ Authorization: `Bearer ${token}`,
635
+ 'Content-Type': 'application/json'
636
+ },
637
+ body: JSON.stringify({
638
+ name: 'match-123',
639
+ balancingStrategy: 'round-robin',
640
+ public: true,
641
+ maxPlayersPerShard: 50
642
+ })
643
+ });
644
+ ```
645
+
646
+ The same `worldId` must be used consistently by:
647
+ - the client request to `connectionWorld()`;
648
+ - the `WorldRoom` party id;
649
+ - the shard metadata/id or shard constructor option.
650
+
493
651
  #### Client Connection
494
652
 
495
653
  On the client side, use the `connectionWorld` function to connect to your room through the World service:
@@ -504,7 +662,7 @@ const room = new YourRoomSchema();
504
662
  const connection = await connectionWorld({
505
663
  host: 'https://your-app-url.com', // Your application URL
506
664
  room: 'unique-room-id', // Room identifier
507
- worldId: 'your-world-id', // Optional, defaults to 'world-default'
665
+ worldId: 'world-eu', // Optional, defaults to 'world-default'
508
666
  autoCreate: true, // Auto-create room if it doesn't exist
509
667
  retryCount: 3, // Number of connection attempts
510
668
  retryDelay: 1000 // Delay between retries in ms
@@ -529,11 +687,14 @@ import { connectionRoom } from '@signe/sync/client';
529
687
 
530
688
  // Initialize your room instance
531
689
  const room = new YourRoomSchema();
690
+ const sessionId = localStorage.getItem('room-session-id') ?? crypto.randomUUID();
691
+ localStorage.setItem('room-session-id', sessionId);
532
692
 
533
693
  // Connect directly to a room
534
694
  const connection = await connectionRoom({
535
695
  host: window.location.origin,
536
696
  room: 'your-room-name',
697
+ id: sessionId,
537
698
  party: 'your-party-name', // Optional, defaults to main party
538
699
  query: {} // Optional query parameters
539
700
  }, room);
@@ -561,6 +722,32 @@ This approach offers several benefits:
561
722
  - Built-in retry logic for reliability
562
723
  - Room creation on demand
563
724
 
725
+ #### Shard Lifecycle Notes
726
+
727
+ World tracks each shard with:
728
+ - `roomId`: the logical room served by the shard;
729
+ - `worldId`: the world that owns the shard;
730
+ - `url`: the shard connection target returned to clients;
731
+ - `currentConnections`: the latest reported connection count;
732
+ - `maxConnections`: the configured shard capacity;
733
+ - `status`: `active`, `maintenance`, or `draining`;
734
+ - `lastHeartbeat`: the latest stats update timestamp.
735
+
736
+ The built-in balancing strategies only consider active shards with available capacity (`currentConnections < maxConnections`):
737
+ - `round-robin`: rotates through available shards;
738
+ - `least-connections`: picks the available shard with the lowest reported connection count;
739
+ - `random`: picks a random available shard.
740
+
741
+ If every active shard is full and `autoCreate` is enabled, the world creates another shard when the room has not reached `maxShards`. If no capacity is available and the room cannot create another shard, `/connect` returns a capacity error.
742
+
743
+ Shard stats are updated when connections change and through periodic forced heartbeats. Inactive shards are removed after the world cleanup timeout.
744
+
745
+ When a shard is marked `draining`, the world stops assigning new clients to it. Existing WebSocket clients remain connected to that shard. Once the shard reports `currentConnections: 0`, the world removes it from the shard registry automatically. Scaling down uses the same flow: empty candidate shards are removed immediately, while occupied candidate shards are marked `draining` and removed later when they become empty.
746
+
747
+ Current limitations:
748
+ - Draining does not migrate connected clients; it waits for them to disconnect naturally.
749
+ - The world registry is held in room state; use deployment-specific persistence if your topology requires an external global registry.
750
+
564
751
  ### Packet Interception
565
752
 
566
753
  You can implement the `interceptorPacket` method in your room to inspect and modify packets before they're sent to users:
@@ -642,6 +829,185 @@ connection.close();
642
829
 
643
830
  > https://docs.partykit.io/reference/partyserver-api/#partyconnection
644
831
 
832
+ ## Node.js adapter
833
+
834
+ `@signe/room/node` runs a room server in a standard single-process Node.js
835
+ application. It is useful for local development, self-hosting, Express/Fastify
836
+ style integrations, Vite dev servers, and tests that do not need PartyKit.
837
+
838
+ ```ts
839
+ import { createServer } from "node:http";
840
+ import { WebSocketServer } from "ws";
841
+ import { Action, Request, Room, Server } from "@signe/room";
842
+ import { createMemoryNodeRoomStorage, createNodeRoomTransport } from "@signe/room/node";
843
+ import { signal } from "@signe/reactive";
844
+ import { sync } from "@signe/sync";
845
+
846
+ @Room({ path: "demo" })
847
+ class CounterRoom {
848
+ @sync() count = signal(0);
849
+
850
+ @Action("increment")
851
+ increment(_user: unknown, value: { amount?: number }) {
852
+ this.count.update((count) => count + (value.amount ?? 1));
853
+ }
854
+
855
+ @Request({ path: "/count" })
856
+ getCount() {
857
+ return { count: this.count() };
858
+ }
859
+ }
860
+
861
+ class CounterServer extends Server {
862
+ rooms = [CounterRoom];
863
+ }
864
+
865
+ const storage = createMemoryNodeRoomStorage();
866
+
867
+ const transport = createNodeRoomTransport(CounterServer, {
868
+ partiesPath: "/parties/main",
869
+ storage,
870
+ });
871
+
872
+ const server = createServer((req, res) => {
873
+ void transport.handleNodeRequest(req, res);
874
+ });
875
+
876
+ const wsServer = new WebSocketServer({ noServer: true });
877
+
878
+ server.on("upgrade", (request, socket, head) => {
879
+ transport.handleUpgrade(wsServer, request, socket, head);
880
+ });
881
+
882
+ server.listen(3000);
883
+ ```
884
+
885
+ HTTP requests use the same PartyKit-style room path:
886
+
887
+ ```bash
888
+ curl http://localhost:3000/parties/main/demo/count
889
+ ```
890
+
891
+ WebSocket clients connect to the room URL and send normal action packets:
892
+
893
+ ```js
894
+ const socket = new WebSocket("ws://localhost:3000/parties/main/demo");
895
+
896
+ socket.send(JSON.stringify({
897
+ action: "increment",
898
+ value: { amount: 1 }
899
+ }));
900
+ ```
901
+
902
+ For middleware frameworks, pass a `next` callback. Requests that do not match
903
+ the configured parties path are delegated to `next`.
904
+
905
+ ```ts
906
+ app.use((req, res, next) => {
907
+ void transport.handleNodeRequest(req, res, next);
908
+ });
909
+ ```
910
+
911
+ Room-to-room requests are available through `room.context.parties`:
912
+
913
+ ```ts
914
+ const response = await this.room.context.parties.main
915
+ .get("other-room")
916
+ .fetch("/count");
917
+ ```
918
+
919
+ The Node adapter stores room state in memory by default. The package also
920
+ provides explicit memory and SQLite storage providers.
921
+
922
+ Use `createMemoryNodeRoomStorage()` when you want to keep a reference to the
923
+ memory backend, inspect it, clear it, or save a snapshot for a later process
924
+ restart.
925
+
926
+ ```ts
927
+ const storage = createMemoryNodeRoomStorage();
928
+
929
+ const transport = createNodeRoomTransport(CounterServer, {
930
+ storage,
931
+ });
932
+
933
+ const snapshot = storage.snapshot();
934
+ const restoredStorage = createMemoryNodeRoomStorage({ snapshot });
935
+ ```
936
+
937
+ Use `createSqliteNodeRoomStorage()` when you want room storage persisted in a
938
+ SQLite database. This helper uses Node's built-in `node:sqlite` module.
939
+
940
+ ```ts
941
+ import {
942
+ createNodeRoomTransport,
943
+ createSqliteNodeRoomStorage,
944
+ } from "@signe/room/node";
945
+
946
+ const transport = createNodeRoomTransport(CounterServer, {
947
+ storage: createSqliteNodeRoomStorage({
948
+ databasePath: "./rooms.sqlite",
949
+ }),
950
+ });
951
+ ```
952
+
953
+ The SQLite helper enables `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode
954
+ = WAL` by default to make development servers more tolerant of short-lived
955
+ write contention. You can override those defaults with `busyTimeoutMs`,
956
+ `journalMode`, and `busyRetries`.
957
+
958
+ To create your own storage backend, implement the key-value methods used by
959
+ `@signe/room`: `get`, `put`, `delete`, and `list`, then return it from a storage
960
+ provider.
961
+
962
+ ```ts
963
+ import type { NodeRoomStorage, NodeRoomStorageProvider } from "@signe/room/node";
964
+
965
+ class MyStorage implements NodeRoomStorage {
966
+ async get<T = unknown>(key: string): Promise<T | undefined> {
967
+ // Read from your database
968
+ }
969
+
970
+ async put<T = unknown>(key: string, value: T): Promise<void> {
971
+ // Write to your database
972
+ }
973
+
974
+ async delete(key: string): Promise<void | boolean> {
975
+ // Delete from your database
976
+ }
977
+
978
+ async list<T = unknown>(): Promise<Map<string, T>> {
979
+ // Return all key/value entries for the room
980
+ }
981
+ }
982
+
983
+ const storage: NodeRoomStorageProvider = {
984
+ getStorage(namespace, roomId) {
985
+ return new MyStorage(namespace, roomId);
986
+ },
987
+ };
988
+
989
+ const transport = createNodeRoomTransport(CounterServer, {
990
+ storage,
991
+ });
992
+ ```
993
+
994
+ To create your own Node transport integration, use the low-level methods exposed
995
+ by `createNodeRoomTransport()`:
996
+
997
+ - `transport.fetch(requestOrPath, init?)` for runtimes using Web
998
+ `Request`/`Response`;
999
+ - `transport.handleNodeRequest(req, res, next?)` for Node HTTP middleware;
1000
+ - `transport.handleUpgrade(wsServer, request, socket, head)` for `ws`
1001
+ WebSocket upgrades;
1002
+ - `transport.acceptWebSocket(webSocket, request)` when your framework already
1003
+ accepted the WebSocket and you only need to attach it to a room.
1004
+
1005
+ The first Node adapter version targets single-process Node.js only; clustering,
1006
+ multi-process coordination, Cloudflare Durable Objects, Bun WebSocket, and
1007
+ uWebSockets.js support are outside this adapter.
1008
+
1009
+ See `packages/room/examples/node` for a runnable HTTP + WebSocket example.
1010
+
645
1011
  ## Testing
646
1012
 
647
1013
  ```ts