@signe/room 2.10.0 → 3.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.
Files changed (84) 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 +87 -188
  8. package/dist/index.js +860 -114
  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 +418 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/index.ts +2 -2
  55. package/src/jwt.ts +1 -5
  56. package/src/mock.ts +29 -7
  57. package/src/node/index.ts +1112 -0
  58. package/src/server.ts +781 -60
  59. package/src/session.guard.ts +6 -2
  60. package/src/shard.ts +91 -23
  61. package/src/storage.ts +29 -5
  62. package/src/testing.ts +4 -3
  63. package/src/types/party.ts +30 -1
  64. package/src/world.guard.ts +23 -4
  65. package/src/world.ts +121 -21
  66. package/tests/storage-restore.spec.ts +122 -0
  67. package/examples/game/.vscode/launch.json +0 -11
  68. package/examples/game/.vscode/settings.json +0 -11
  69. package/examples/game/README.md +0 -40
  70. package/examples/game/app/client.tsx +0 -15
  71. package/examples/game/app/components/Admin.tsx +0 -1089
  72. package/examples/game/app/components/Room.tsx +0 -162
  73. package/examples/game/app/styles.css +0 -31
  74. package/examples/game/package-lock.json +0 -225
  75. package/examples/game/package.json +0 -20
  76. package/examples/game/party/game.room.ts +0 -32
  77. package/examples/game/party/server.ts +0 -10
  78. package/examples/game/party/shard.ts +0 -5
  79. package/examples/game/partykit.json +0 -14
  80. package/examples/game/public/favicon.ico +0 -0
  81. package/examples/game/public/index.html +0 -27
  82. package/examples/game/public/normalize.css +0 -351
  83. package/examples/game/shared/room.schema.ts +0 -14
  84. 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
 
@@ -249,6 +299,47 @@ const hydrated = { ...snapshot, items };
249
299
  load(user, hydrated, true);
250
300
  ```
251
301
 
302
+ ### Storage Restore Hydration
303
+
304
+ Room storage is loaded automatically when a room starts. If persisted snapshots
305
+ contain complex values that must become runtime instances again, implement
306
+ `onStorageRestore` or `onUserStorageRestore` on the room.
307
+
308
+ Use `onStorageRestore` to transform the full room snapshot before it is loaded:
309
+
310
+ ```ts
311
+ class GameRoom {
312
+ async onStorageRestore({ snapshot, room, legacy }) {
313
+ return {
314
+ ...snapshot,
315
+ status: snapshot.status ?? "waiting",
316
+ };
317
+ }
318
+ }
319
+ ```
320
+
321
+ Use `onUserStorageRestore` to transform each persisted entry in the room's
322
+ `@users()` collection. The hook receives a fresh user helper instance so you can
323
+ reuse instance methods to hydrate nested data before the snapshot is loaded.
324
+
325
+ ```ts
326
+ class GameRoom {
327
+ @users(Player) players = signal({});
328
+
329
+ async onUserStorageRestore({ userSnapshot, user, publicId }) {
330
+ return {
331
+ ...userSnapshot,
332
+ items: await user.resolveItems(userSnapshot.items),
333
+ skills: await user.resolveSkills(userSnapshot.skills),
334
+ };
335
+ }
336
+ }
337
+ ```
338
+
339
+ Returning `undefined` keeps the original snapshot unchanged. The `legacy` flag is
340
+ `true` only when loading data from the pre-`state:` storage layout during
341
+ automatic migration.
342
+
252
343
  ### Room Configuration
253
344
 
254
345
  The `@Room` decorator accepts various configuration options:
@@ -435,6 +526,8 @@ class GameRoom {
435
526
 
436
527
  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
528
 
529
+ 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.
530
+
438
531
  #### Environment Variables
439
532
 
440
533
  To use the Signe room system, you need to configure two essential environment variables:
@@ -447,7 +540,7 @@ AUTH_JWT_SECRET=a-string-secret-at-least-256-bits-long
447
540
  SHARD_SECRET=your_shard_secret
448
541
  ```
449
542
 
450
- These secrets should be strong, unique values and kept secure.
543
+ 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
544
 
452
545
  #### Server Configuration
453
546
 
@@ -474,6 +567,34 @@ import { Shard } from '@signe/room';
474
567
  export default class ShardServer extends Shard {}
475
568
  ```
476
569
 
570
+ 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:
571
+
572
+ ```ts
573
+ import { Shard, type Party } from '@signe/room';
574
+
575
+ export default class EuShardServer extends Shard {
576
+ constructor(room: Party.Room) {
577
+ super(room, {
578
+ worldId: 'world-eu'
579
+ });
580
+ }
581
+ }
582
+ ```
583
+
584
+ If you let `WorldRoom` create shard metadata, shard ids use this format:
585
+
586
+ ```txt
587
+ {roomId}:{worldId}:{uniqueShardId}
588
+ ```
589
+
590
+ For example:
591
+
592
+ ```txt
593
+ match-123:world-eu:1710000000000-4821
594
+ ```
595
+
596
+ The `Shard` class can read `world-eu` from that id and report stats back to the matching world.
597
+
477
598
  3. Configure your `partykit.json` file:
478
599
 
479
600
  ```json
@@ -489,6 +610,85 @@ export default class ShardServer extends Shard {}
489
610
  }
490
611
  ```
491
612
 
613
+ #### Multi-World Setup
614
+
615
+ `WorldRoom` uses the dynamic path `world-{worldId}`. This means these are separate world instances:
616
+
617
+ ```txt
618
+ /parties/world/world-default
619
+ /parties/world/world-eu
620
+ /parties/world/world-us
621
+ ```
622
+
623
+ 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.
624
+
625
+ When a client connects through a specific world, pass the same `worldId` to `connectionWorld()`:
626
+
627
+ ```js
628
+ const euConnection = await connectionWorld({
629
+ host: 'https://your-app-url.com',
630
+ room: 'match-123',
631
+ worldId: 'world-eu',
632
+ autoCreate: true
633
+ }, room);
634
+ ```
635
+
636
+ For an admin connection to a world room, use the world id as the room id:
637
+
638
+ ```js
639
+ const worldConnection = await connectionRoom({
640
+ host: window.location.origin,
641
+ room: 'world-eu',
642
+ party: 'world',
643
+ query: {
644
+ 'world-auth-token': 'your-jwt-token'
645
+ }
646
+ }, worldRoom);
647
+ ```
648
+
649
+ #### World Admin Authorization
650
+
651
+ 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:
652
+
653
+ ```json
654
+ {
655
+ "sub": "operator-1",
656
+ "worlds": ["world-default", "world-eu"]
657
+ }
658
+ ```
659
+
660
+ Use `["*"]` for a global operator:
661
+
662
+ ```json
663
+ {
664
+ "sub": "admin",
665
+ "worlds": ["*"]
666
+ }
667
+ ```
668
+
669
+ 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:
670
+
671
+ ```js
672
+ await fetch('/parties/world/world-eu/register-room', {
673
+ method: 'POST',
674
+ headers: {
675
+ Authorization: `Bearer ${token}`,
676
+ 'Content-Type': 'application/json'
677
+ },
678
+ body: JSON.stringify({
679
+ name: 'match-123',
680
+ balancingStrategy: 'round-robin',
681
+ public: true,
682
+ maxPlayersPerShard: 50
683
+ })
684
+ });
685
+ ```
686
+
687
+ The same `worldId` must be used consistently by:
688
+ - the client request to `connectionWorld()`;
689
+ - the `WorldRoom` party id;
690
+ - the shard metadata/id or shard constructor option.
691
+
492
692
  #### Client Connection
493
693
 
494
694
  On the client side, use the `connectionWorld` function to connect to your room through the World service:
@@ -503,7 +703,7 @@ const room = new YourRoomSchema();
503
703
  const connection = await connectionWorld({
504
704
  host: 'https://your-app-url.com', // Your application URL
505
705
  room: 'unique-room-id', // Room identifier
506
- worldId: 'your-world-id', // Optional, defaults to 'world-default'
706
+ worldId: 'world-eu', // Optional, defaults to 'world-default'
507
707
  autoCreate: true, // Auto-create room if it doesn't exist
508
708
  retryCount: 3, // Number of connection attempts
509
709
  retryDelay: 1000 // Delay between retries in ms
@@ -528,11 +728,14 @@ import { connectionRoom } from '@signe/sync/client';
528
728
 
529
729
  // Initialize your room instance
530
730
  const room = new YourRoomSchema();
731
+ const sessionId = localStorage.getItem('room-session-id') ?? crypto.randomUUID();
732
+ localStorage.setItem('room-session-id', sessionId);
531
733
 
532
734
  // Connect directly to a room
533
735
  const connection = await connectionRoom({
534
736
  host: window.location.origin,
535
737
  room: 'your-room-name',
738
+ id: sessionId,
536
739
  party: 'your-party-name', // Optional, defaults to main party
537
740
  query: {} // Optional query parameters
538
741
  }, room);
@@ -560,6 +763,32 @@ This approach offers several benefits:
560
763
  - Built-in retry logic for reliability
561
764
  - Room creation on demand
562
765
 
766
+ #### Shard Lifecycle Notes
767
+
768
+ World tracks each shard with:
769
+ - `roomId`: the logical room served by the shard;
770
+ - `worldId`: the world that owns the shard;
771
+ - `url`: the shard connection target returned to clients;
772
+ - `currentConnections`: the latest reported connection count;
773
+ - `maxConnections`: the configured shard capacity;
774
+ - `status`: `active`, `maintenance`, or `draining`;
775
+ - `lastHeartbeat`: the latest stats update timestamp.
776
+
777
+ The built-in balancing strategies only consider active shards with available capacity (`currentConnections < maxConnections`):
778
+ - `round-robin`: rotates through available shards;
779
+ - `least-connections`: picks the available shard with the lowest reported connection count;
780
+ - `random`: picks a random available shard.
781
+
782
+ 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.
783
+
784
+ Shard stats are updated when connections change and through periodic forced heartbeats. Inactive shards are removed after the world cleanup timeout.
785
+
786
+ 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.
787
+
788
+ Current limitations:
789
+ - Draining does not migrate connected clients; it waits for them to disconnect naturally.
790
+ - The world registry is held in room state; use deployment-specific persistence if your topology requires an external global registry.
791
+
563
792
  ### Packet Interception
564
793
 
565
794
  You can implement the `interceptorPacket` method in your room to inspect and modify packets before they're sent to users:
@@ -641,6 +870,191 @@ connection.close();
641
870
 
642
871
  > https://docs.partykit.io/reference/partyserver-api/#partyconnection
643
872
 
873
+ ## Node.js adapter
874
+
875
+ `@signe/room/node` runs a room server in a standard single-process Node.js
876
+ application. It is useful for local development, self-hosting, Express/Fastify
877
+ style integrations, Vite dev servers, and tests that do not need PartyKit.
878
+
879
+ ```ts
880
+ import { createServer } from "node:http";
881
+ import { WebSocketServer } from "ws";
882
+ import { Action, Request, Room, Server } from "@signe/room";
883
+ import { createMemoryNodeRoomStorage, createNodeRoomTransport } from "@signe/room/node";
884
+ import { signal } from "@signe/reactive";
885
+ import { sync } from "@signe/sync";
886
+
887
+ @Room({ path: "demo" })
888
+ class CounterRoom {
889
+ @sync() count = signal(0);
890
+
891
+ @Action("increment")
892
+ increment(_user: unknown, value: { amount?: number }) {
893
+ this.count.update((count) => count + (value.amount ?? 1));
894
+ }
895
+
896
+ @Request({ path: "/count" })
897
+ getCount() {
898
+ return { count: this.count() };
899
+ }
900
+ }
901
+
902
+ class CounterServer extends Server {
903
+ rooms = [CounterRoom];
904
+ }
905
+
906
+ const storage = createMemoryNodeRoomStorage();
907
+
908
+ const transport = createNodeRoomTransport(CounterServer, {
909
+ partiesPath: "/parties/main",
910
+ storage,
911
+ });
912
+
913
+ const server = createServer((req, res) => {
914
+ void transport.handleNodeRequest(req, res);
915
+ });
916
+
917
+ const wsServer = new WebSocketServer({ noServer: true });
918
+
919
+ server.on("upgrade", (request, socket, head) => {
920
+ transport.handleUpgrade(wsServer, request, socket, head);
921
+ });
922
+
923
+ server.listen(3000);
924
+ ```
925
+
926
+ HTTP requests use the same PartyKit-style room path:
927
+
928
+ ```bash
929
+ curl http://localhost:3000/parties/main/demo/count
930
+ ```
931
+
932
+ WebSocket clients connect to the room URL and send normal action packets:
933
+
934
+ ```js
935
+ const socket = new WebSocket("ws://localhost:3000/parties/main/demo");
936
+
937
+ socket.send(JSON.stringify({
938
+ action: "increment",
939
+ value: { amount: 1 }
940
+ }));
941
+ ```
942
+
943
+ For middleware frameworks, pass a `next` callback. Requests that do not match
944
+ the configured parties path are delegated to `next`.
945
+
946
+ ```ts
947
+ app.use((req, res, next) => {
948
+ void transport.handleNodeRequest(req, res, next);
949
+ });
950
+ ```
951
+
952
+ Room-to-room requests are available through `room.context.parties`:
953
+
954
+ ```ts
955
+ const response = await this.room.context.parties.main
956
+ .get("other-room")
957
+ .fetch("/count");
958
+ ```
959
+
960
+ The Node adapter stores room state in memory by default. The package also
961
+ provides explicit memory and SQLite storage providers.
962
+
963
+ Use `createMemoryNodeRoomStorage()` when you want to keep a reference to the
964
+ memory backend, inspect it, clear it, or save a snapshot for a later process
965
+ restart.
966
+
967
+ ```ts
968
+ const storage = createMemoryNodeRoomStorage();
969
+
970
+ const transport = createNodeRoomTransport(CounterServer, {
971
+ storage,
972
+ });
973
+
974
+ const snapshot = storage.snapshot();
975
+ const restoredStorage = createMemoryNodeRoomStorage({ snapshot });
976
+ ```
977
+
978
+ Use `createSqliteNodeRoomStorage()` when you want room storage persisted in a
979
+ SQLite database. This helper uses Node's built-in `node:sqlite` module.
980
+
981
+ ```ts
982
+ import {
983
+ createNodeRoomTransport,
984
+ createSqliteNodeRoomStorage,
985
+ } from "@signe/room/node";
986
+
987
+ const transport = createNodeRoomTransport(CounterServer, {
988
+ storage: createSqliteNodeRoomStorage({
989
+ databasePath: "./rooms.sqlite",
990
+ }),
991
+ });
992
+ ```
993
+
994
+ The SQLite helper enables `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode
995
+ = WAL` by default to make development servers more tolerant of short-lived
996
+ write contention. You can override those defaults with `busyTimeoutMs`,
997
+ `journalMode`, and `busyRetries`.
998
+
999
+ Room state is stored as incremental `state:` entries. When a persisted delete is
1000
+ encountered, the server compacts the room state by materializing the current
1001
+ snapshot and removing durable delete markers. This keeps long-running SQLite
1002
+ storage from accumulating `"$delete"` tombstones after objects or users are
1003
+ removed.
1004
+
1005
+ To create your own storage backend, implement the key-value methods used by
1006
+ `@signe/room`: `get`, `put`, `delete`, and `list`, then return it from a storage
1007
+ provider.
1008
+
1009
+ ```ts
1010
+ import type { NodeRoomStorage, NodeRoomStorageProvider } from "@signe/room/node";
1011
+
1012
+ class MyStorage implements NodeRoomStorage {
1013
+ async get<T = unknown>(key: string): Promise<T | undefined> {
1014
+ // Read from your database
1015
+ }
1016
+
1017
+ async put<T = unknown>(key: string, value: T): Promise<void> {
1018
+ // Write to your database
1019
+ }
1020
+
1021
+ async delete(key: string): Promise<void | boolean> {
1022
+ // Delete from your database
1023
+ }
1024
+
1025
+ async list<T = unknown>(): Promise<Map<string, T>> {
1026
+ // Return all key/value entries for the room
1027
+ }
1028
+ }
1029
+
1030
+ const storage: NodeRoomStorageProvider = {
1031
+ getStorage(namespace, roomId) {
1032
+ return new MyStorage(namespace, roomId);
1033
+ },
1034
+ };
1035
+
1036
+ const transport = createNodeRoomTransport(CounterServer, {
1037
+ storage,
1038
+ });
1039
+ ```
1040
+
1041
+ To create your own Node transport integration, use the low-level methods exposed
1042
+ by `createNodeRoomTransport()`:
1043
+
1044
+ - `transport.fetch(requestOrPath, init?)` for runtimes using Web
1045
+ `Request`/`Response`;
1046
+ - `transport.handleNodeRequest(req, res, next?)` for Node HTTP middleware;
1047
+ - `transport.handleUpgrade(wsServer, request, socket, head)` for `ws`
1048
+ WebSocket upgrades;
1049
+ - `transport.acceptWebSocket(webSocket, request)` when your framework already
1050
+ accepted the WebSocket and you only need to attach it to a room.
1051
+
1052
+ The first Node adapter version targets single-process Node.js only; clustering,
1053
+ multi-process coordination, Cloudflare Durable Objects, Bun WebSocket, and
1054
+ uWebSockets.js support are outside this adapter.
1055
+
1056
+ See `packages/room/examples/node` for a runnable HTTP + WebSocket example.
1057
+
644
1058
  ## Testing
645
1059
 
646
1060
  ```ts