@signe/room 2.10.0 → 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 (82) hide show
  1. package/CHANGELOG.md +7 -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 +66 -187
  8. package/dist/index.js +727 -106
  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 +371 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/jwt.ts +1 -5
  55. package/src/mock.ts +29 -7
  56. package/src/node/index.ts +1112 -0
  57. package/src/server.ts +600 -51
  58. package/src/session.guard.ts +6 -2
  59. package/src/shard.ts +91 -23
  60. package/src/storage.ts +29 -5
  61. package/src/testing.ts +4 -3
  62. package/src/types/party.ts +4 -1
  63. package/src/world.guard.ts +23 -4
  64. package/src/world.ts +121 -21
  65. package/examples/game/.vscode/launch.json +0 -11
  66. package/examples/game/.vscode/settings.json +0 -11
  67. package/examples/game/README.md +0 -40
  68. package/examples/game/app/client.tsx +0 -15
  69. package/examples/game/app/components/Admin.tsx +0 -1089
  70. package/examples/game/app/components/Room.tsx +0 -162
  71. package/examples/game/app/styles.css +0 -31
  72. package/examples/game/package-lock.json +0 -225
  73. package/examples/game/package.json +0 -20
  74. package/examples/game/party/game.room.ts +0 -32
  75. package/examples/game/party/server.ts +0 -10
  76. package/examples/game/party/shard.ts +0 -5
  77. package/examples/game/partykit.json +0 -14
  78. package/examples/game/public/favicon.ico +0 -0
  79. package/examples/game/public/index.html +0 -27
  80. package/examples/game/public/normalize.css +0 -351
  81. package/examples/game/shared/room.schema.ts +0 -14
  82. 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
  ```
@@ -182,10 +185,57 @@ You can return:
182
185
 
183
186
  ## Advanced Features
184
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
+
185
234
  ### Session Transfer
186
235
 
187
236
  You can transfer a user's session from one room to another using `$sessionTransfer`.
188
- 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.
189
239
 
190
240
  Server-side (inside a room or action):
191
241
 
@@ -435,6 +485,8 @@ class GameRoom {
435
485
 
436
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.
437
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
+
438
490
  #### Environment Variables
439
491
 
440
492
  To use the Signe room system, you need to configure two essential environment variables:
@@ -447,7 +499,7 @@ AUTH_JWT_SECRET=a-string-secret-at-least-256-bits-long
447
499
  SHARD_SECRET=your_shard_secret
448
500
  ```
449
501
 
450
- 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.
451
503
 
452
504
  #### Server Configuration
453
505
 
@@ -474,6 +526,34 @@ import { Shard } from '@signe/room';
474
526
  export default class ShardServer extends Shard {}
475
527
  ```
476
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
+
477
557
  3. Configure your `partykit.json` file:
478
558
 
479
559
  ```json
@@ -489,6 +569,85 @@ export default class ShardServer extends Shard {}
489
569
  }
490
570
  ```
491
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
+
492
651
  #### Client Connection
493
652
 
494
653
  On the client side, use the `connectionWorld` function to connect to your room through the World service:
@@ -503,7 +662,7 @@ const room = new YourRoomSchema();
503
662
  const connection = await connectionWorld({
504
663
  host: 'https://your-app-url.com', // Your application URL
505
664
  room: 'unique-room-id', // Room identifier
506
- worldId: 'your-world-id', // Optional, defaults to 'world-default'
665
+ worldId: 'world-eu', // Optional, defaults to 'world-default'
507
666
  autoCreate: true, // Auto-create room if it doesn't exist
508
667
  retryCount: 3, // Number of connection attempts
509
668
  retryDelay: 1000 // Delay between retries in ms
@@ -528,11 +687,14 @@ import { connectionRoom } from '@signe/sync/client';
528
687
 
529
688
  // Initialize your room instance
530
689
  const room = new YourRoomSchema();
690
+ const sessionId = localStorage.getItem('room-session-id') ?? crypto.randomUUID();
691
+ localStorage.setItem('room-session-id', sessionId);
531
692
 
532
693
  // Connect directly to a room
533
694
  const connection = await connectionRoom({
534
695
  host: window.location.origin,
535
696
  room: 'your-room-name',
697
+ id: sessionId,
536
698
  party: 'your-party-name', // Optional, defaults to main party
537
699
  query: {} // Optional query parameters
538
700
  }, room);
@@ -560,6 +722,32 @@ This approach offers several benefits:
560
722
  - Built-in retry logic for reliability
561
723
  - Room creation on demand
562
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
+
563
751
  ### Packet Interception
564
752
 
565
753
  You can implement the `interceptorPacket` method in your room to inspect and modify packets before they're sent to users:
@@ -641,6 +829,185 @@ connection.close();
641
829
 
642
830
  > https://docs.partykit.io/reference/partyserver-api/#partyconnection
643
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
+
644
1011
  ## Testing
645
1012
 
646
1013
  ```ts