@signe/room
v3.0.1
Published
PartyKit room primitives with synchronized state, sessions, guards, and HTTP handlers.
Maintainers
Readme
@signe/room
A real-time multiplayer room system for Signe applications, providing seamless state synchronization and user management.
Installation
npm install @signe/room @signe/reactive @signe/syncFeatures
- 🔄 Automatic state synchronization across clients
- 👥 Built-in user management with customizable player classes
- 🎮 Action-based message handling with type safety
- 🌐 HTTP request routing with path parameters
- 🔐 Flexible authentication and authorization system
- 🛡️ Guard system for room and action-level security
- 🎯 Full TypeScript support
- 🔌 WebSocket-based real-time communication
- 💾 Automatic state persistence
- 🚀 Optimized for performance with throttling support
Basic Usage
Here's a simple example of a multiplayer game room:
import { signal } from "@signe/reactive";
import { Room, Server, Action } from "@signe/room";
import { id, sync, users } from "@signe/sync";
// Define a Player class
class Player {
@id() id: string;
@sync() x = signal(0);
@sync() y = signal(0);
@sync() score = signal(0);
}
// Create your room
@Room({
path: "game",
})
class GameRoom {
@users(Player) players = signal({});
@sync() gameState = signal("waiting");
@Action("move")
move(player: Player, position: { x: number, y: number }) {
player.x.set(position.x);
player.y.set(position.y);
}
}
// Create your server
export default class GameServer extends Server {
rooms = [GameRoom];
}Action
An action is a function that is called when a client sends a message to the server.
Function have to be decorated with the @Action decorator and have 3 parameters:
- The first parameter is the player instance
- The second parameter is the value of the action
- The third parameter is the Party.Connection instance
Unhandled actions
If you want to catch any valid WebSocket message whose action is not registered
with @Action(...), you can use @UnhandledAction().
The fallback handler receives:
- The first parameter is the player instance
- The second parameter is the full message object:
{ action, value } - The third parameter is the
Party.Connectioninstance
import { Action, Guard, Room, UnhandledAction } from "@signe/room";
function isAuthenticated(conn: Party.Connection, value: any, room: Party.Room) {
return !!conn.state?.publicId;
}
@Room({
path: "game",
})
class GameRoom {
@Action("move")
move(player: any, value: { x: number; y: number }) {
player.x.set(value.x);
player.y.set(value.y);
}
@UnhandledAction()
@Guard([isAuthenticated])
onUnhandledAction(
player: any,
message: { action: string; value: unknown },
conn: Party.Connection
) {
console.warn("Unhandled action", message.action, message.value, {
connectionId: conn.id,
sessionId: conn.sessionId,
});
}
}Notes:
@UnhandledAction()is only called if the incoming message matches the expected WebSocket shape{ action, value }- If a matching
@Action("...")exists, it always has priority over@UnhandledAction() - You can combine
@UnhandledAction()with@Guard(...)
HTTP Request Handling
The @Request decorator allows you to handle HTTP requests with specific routes and methods:
import { z } from "zod";
import { Room, Request, RequestGuard, ServerResponse } from "@signe/room";
@Room({
path: "api"
})
class ApiRoom {
@sync() gameState = signal("waiting");
@users(Player) players = signal({});
@sync() scores = signal([]);
// Handle GET requests
@Request({ path: "/status" })
getStatus(req: Party.Request) {
return {
status: "online",
players: Object.keys(this.players()).length,
gameState: this.gameState(),
};
}
// Handle requests with path parameters
@Request({ path: "/players/:id" })
getPlayer(req: Party.Request, res: ServerResponse) {
const player = this.players()[req.params.id];
if (!player) {
return res.notFound("Player not found");
}
return player;
}
// Handle POST requests with body validation
@Request(
{ path: "/scores", method: "POST" },
z.object({
playerId: z.string(),
score: z.number().min(0)
})
)
@Guard([isAuthenticated])
submitScore(req: Party.Request, res: ServerResponse) {
this.scores.update(scores => [...scores, req.data]);
return res.success({ success: true });
}
}Request handler methods receive these parameters:
req: The original Party.Request objectbody: The validated request body (if validation schema was provided)params: An object containing any path parametersroom: The Party.Room instance
You can return:
- A Response object for complete control
- An object that will be serialized as JSON
- A string that will be returned as text/plain
Advanced Features
User Sessions and Reconnects
Rooms use the WebSocket id query parameter as the private session id
(privateId). Each active WebSocket still receives its own unique
conn.id; the stable session id is available as conn.sessionId. The
corresponding publicId is the key used in @users() collections. Query
parameters such as a display name are user data only; they do not identify the
session.
Identifier summary:
| Identifier | Meaning | Stability |
| --- | --- | --- |
| conn.id | Unique WebSocket connection id | New for every active socket |
| conn.sessionId | Private session id from the WebSocket id query parameter | Stable across reconnects and shared by tabs using the same id |
| publicId | User id stored in @users() collections | Stable for the restored session |
Use conn.id when you need to address or exclude one physical WebSocket, for
example room.broadcast(message, [conn.id]). Use conn.sessionId when you
need to read, restore, transfer, or log the private user session.
Pass a stable id when connecting if a browser refresh or reconnect should
restore the same user. If id is omitted, each connection creates a new
session and therefore a new user entry. To implement logout, remove or rotate
the stored id before reconnecting.
Multiple active WebSockets can use the same session id. They share the same
publicId, receive broadcasts independently, and the user is marked offline
only after the last connection for that session closes.
import { connectionRoom } from "@signe/sync/client";
const sessionId =
localStorage.getItem("room-session-id") ?? crypto.randomUUID();
localStorage.setItem("room-session-id", sessionId);
await connectionRoom({
host: window.location.origin,
room: "your-room-name",
party: "main",
id: sessionId,
}, roomInstance);Session Transfer
You can transfer a user's session from one room to another using $sessionTransfer.
This is an advanced use of the same session mechanism and preserves the same
private session id (privateId) across rooms.
Server-side (inside a room or action):
@Action("transfer")
async transfer(player: Player, data: { targetRoomId: string }, conn: Party.Connection) {
const transferToken = await this.$sessionTransfer(conn, data.targetRoomId);
return { transferToken };
}Client-side:
- Connect to the target room with the same session id (
privateId). - You can pass it as
idinconnectionRoomoptions from@signe/sync/client. - The target room restores the session and user data.
Example (client):
import { connectionRoom } from "@signe/sync/client";
await connectionRoom(
{
host: "https://your-host",
room: "targetRoomId",
id: "private-session-id",
},
roomInstance
);Optional: hydrate transferred snapshots before loading
If your user snapshot contains ids for complex instances (e.g. inventory items),
implement onSessionRestore on the room to resolve ids into instances before load.
class GameRoom {
async onSessionRestore({ userSnapshot }) {
if (Array.isArray(userSnapshot.items)) {
const items = await this.itemRegistry.resolveMany(userSnapshot.items);
return { ...userSnapshot, items };
}
return userSnapshot;
}
}Snapshot Hydration (Ids -> Instances)
When a snapshot only contains ids for complex objects, you need to resolve them
before calling load. This is useful even outside session transfer.
const snapshot = createStatesSnapshotDeep(user);
// Resolve ids to instances
const items = await itemRegistry.resolveMany(snapshot.items);
// Hydrate and load
const hydrated = { ...snapshot, items };
load(user, hydrated, true);Storage Restore Hydration
Room storage is loaded automatically when a room starts. If persisted snapshots
contain complex values that must become runtime instances again, implement
onStorageRestore or onUserStorageRestore on the room.
Use onStorageRestore to transform the full room snapshot before it is loaded:
class GameRoom {
async onStorageRestore({ snapshot, room, legacy }) {
return {
...snapshot,
status: snapshot.status ?? "waiting",
};
}
}Use onUserStorageRestore to transform each persisted entry in the room's
@users() collection. The hook receives a fresh user helper instance so you can
reuse instance methods to hydrate nested data before the snapshot is loaded.
class GameRoom {
@users(Player) players = signal({});
async onUserStorageRestore({ userSnapshot, user, publicId }) {
return {
...userSnapshot,
items: await user.resolveItems(userSnapshot.items),
skills: await user.resolveSkills(userSnapshot.skills),
};
}
}Returning undefined keeps the original snapshot unchanged. The legacy flag is
true only when loading data from the pre-state: storage layout during
automatic migration.
Room Configuration
The @Room decorator accepts various configuration options:
@Room({
path: "game-{id}", // Dynamic path with parameters
maxUsers: 4, // Limit number of users
throttleStorage: 1000, // Throttle storage updates (ms)
throttleSync: 100, // Throttle sync updates (ms)
hibernate: false, // Enable/disable hibernation
guards: [isAuthenticated], // Room-level guards
})Authentication & Authorization
You can implement authentication and authorization using guards:
// Authentication guard
function isAuthenticated(conn: Connection, ctx: ConnectionContext) {
const token = ctx.request.headers.get("authorization");
return validateToken(token); // Returns boolean or Promise<boolean>
}
// Role-based guard
function isAdmin(conn: Connection, value: any) {
return conn.state.role === "admin";
}
@Room({
path: "admin-panel",
guards: [isAuthenticated], // Applied to all connections and messages
})
class AdminRoom {
@Action("deleteUser")
@Guard([isAdmin]) // Applied only to this action
async deleteUser(admin: Player, userId: string) {
// Only authenticated admins can execute this
}
@Request({ path: "/admin/users", method: "DELETE" })
@Guard([isAdmin]) // Applied only to this request handler
async deleteUserViaHttp(req: Party.Request) {
// Only authenticated admins can access this endpoint
}
}Action Validation with Zod
You can validate action input data using Zod schemas:
import { z } from "zod";
class GameRoom {
@Action("move", z.object({
x: z.number().min(0).max(1000),
y: z.number().min(0).max(1000)
}))
move(player: Player, position: { x: number, y: number }) {
player.x.set(position.x);
player.y.set(position.y);
}
@Action("setName", z.object({
name: z.string().min(3).max(20)
}))
setName(player: Player, data: { name: string }) {
player.name.set(data.name);
}
}Actions with invalid data will be automatically rejected if they don't match the validation schema.
State Management
The room system provides several ways to manage state:
class GameRoom {
// Synchronized signals
@sync() score = signal(0);
@sync() gameState = signal<"waiting" | "playing" | "ended">("waiting");
// User management
@users(Player) players = signal({});
// Complex state
@sync()
gameConfig = signal({
maxPlayers: 4,
timeLimit: 300,
mapSize: { width: 1000, height: 1000 }
});
// Methods to update state
@Action("updateConfig")
updateConfig(player: Player, config: Partial<GameConfig>) {
if (player.isHost) {
this.gameConfig.update(current => ({
...current,
...config
}));
}
}
}Manual Synchronization
By default, state changes are automatically synchronized to all clients. However, you can disable automatic synchronization and manually control when to broadcast changes using the autoSync option and the $applySync method.
This is useful when you want to:
- Batch multiple state changes before broadcasting
- Control synchronization timing for performance optimization
- Implement custom synchronization logic
@Room({
path: "game"
})
class GameRoom {
autoSync = false; // Disable automatic synchronization
@sync() count = signal(0);
@sync() score = signal(0);
@sync() level = signal(1);
@Action("updateGameState")
updateGameState(player: Player, data: { count: number, score: number, level: number }) {
// Make multiple changes without triggering sync
this.count.set(data.count);
this.score.set(data.score);
this.level.set(data.level);
// Manually trigger synchronization when ready
this.$applySync();
}
@Action("batchUpdate")
batchUpdate(player: Player, updates: any[]) {
// Apply multiple updates
updates.forEach(update => {
// ... apply updates
});
// Sync all changes at once
this.$applySync();
}
}You can also toggle autoSync at runtime:
class GameRoom {
@sync() count = signal(0);
@Action("startBatchMode")
startBatchMode() {
this.$autoSync = false; // Disable auto sync
}
@Action("endBatchMode")
endBatchMode() {
this.$applySync(); // Sync pending changes
this.$autoSync = true; // Re-enable auto sync
}
}Instance Properties:
$autoSync(boolean): Controls whether synchronization happens automatically (default:true)$pendingSync(Map): Stores pending synchronization changes whenautoSyncis disabled$applySync()(method): Manually broadcasts all pending changes to all clients
Note: When autoSync is disabled, changes are stored in $pendingSync until you call $applySync(). If you call $applySync() with no pending changes, it will broadcast the current state from $memoryAll, which is useful for forcing a full state synchronization.
Connecting to World Service
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.
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.
Environment Variables
To use the Signe room system, you need to configure two essential environment variables:
# Required for JWT authentication
AUTH_JWT_SECRET=a-string-secret-at-least-256-bits-long
# Required for secure communication between shards
SHARD_SECRET=your_shard_secretThese 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.
Server Configuration
To use the World service, you need to:
- Add
WorldRoomto your server:
import { Server, WorldRoom } from '@signe/room';
export default class MainServer extends Server {
rooms = [
GameRoom,
WorldRoom // Add WorldRoom to enable World service
]
}- Add
Shardto your server inparty/shard.ts:
import { Shard } from '@signe/room';
export default class ShardServer extends Shard {}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:
import { Shard, type Party } from '@signe/room';
export default class EuShardServer extends Shard {
constructor(room: Party.Room) {
super(room, {
worldId: 'world-eu'
});
}
}If you let WorldRoom create shard metadata, shard ids use this format:
{roomId}:{worldId}:{uniqueShardId}For example:
match-123:world-eu:1710000000000-4821The Shard class can read world-eu from that id and report stats back to the matching world.
- Configure your
partykit.jsonfile:
{
"$schema": "https://www.partykit.io/schema.json",
"name": "yourapp",
"main": "party/server.ts",
"compatibilityDate": "2025-02-04",
"parties": {
"shard": "party/shard.ts", // Shard implementation
"world": "party/server.ts" // World service implementation
}
}Multi-World Setup
WorldRoom uses the dynamic path world-{worldId}. This means these are separate world instances:
/parties/world/world-default
/parties/world/world-eu
/parties/world/world-usEach 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.
When a client connects through a specific world, pass the same worldId to connectionWorld():
const euConnection = await connectionWorld({
host: 'https://your-app-url.com',
room: 'match-123',
worldId: 'world-eu',
autoCreate: true
}, room);For an admin connection to a world room, use the world id as the room id:
const worldConnection = await connectionRoom({
host: window.location.origin,
room: 'world-eu',
party: 'world',
query: {
'world-auth-token': 'your-jwt-token'
}
}, worldRoom);World Admin Authorization
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:
{
"sub": "operator-1",
"worlds": ["world-default", "world-eu"]
}Use ["*"] for a global operator:
{
"sub": "admin",
"worlds": ["*"]
}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:
await fetch('/parties/world/world-eu/register-room', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'match-123',
balancingStrategy: 'round-robin',
public: true,
maxPlayersPerShard: 50
})
});The same worldId must be used consistently by:
- the client request to
connectionWorld(); - the
WorldRoomparty id; - the shard metadata/id or shard constructor option.
Client Connection
On the client side, use the connectionWorld function to connect to your room through the World service:
import { connectionWorld } from '@signe/sync/client';
// Initialize your room instance
const room = new YourRoomSchema();
// Connect through the World service
const connection = await connectionWorld({
host: 'https://your-app-url.com', // Your application URL
room: 'unique-room-id', // Room identifier
worldId: 'world-eu', // Optional, defaults to 'world-default'
autoCreate: true, // Auto-create room if it doesn't exist
retryCount: 3, // Number of connection attempts
retryDelay: 1000 // Delay between retries in ms
}, room);
// Listen for events
connection.on('customEvent', (data) => {
console.log('Received custom event:', data);
});
// Send events to the room
connection.emit('increment', { value: 1 });
// Close the connection when done
connection.close();For connecting to a standard room (not through World service), use the connectionRoom function:
import { connectionRoom } from '@signe/sync/client';
// Initialize your room instance
const room = new YourRoomSchema();
const sessionId = localStorage.getItem('room-session-id') ?? crypto.randomUUID();
localStorage.setItem('room-session-id', sessionId);
// Connect directly to a room
const connection = await connectionRoom({
host: window.location.origin,
room: 'your-room-name',
id: sessionId,
party: 'your-party-name', // Optional, defaults to main party
query: {} // Optional query parameters
}, room);
// For connecting to a World room with authentication
const worldConnection = await connectionRoom({
host: window.location.origin,
room: 'world-default',
party: 'world',
query: {
// Use pre-generated JWT token for authentication
'world-auth-token': 'your-jwt-token'
}
}, worldRoom);The connectionWorld function:
- Queries the World service to find the optimal shard for the requested room
- Establishes a WebSocket connection to the assigned shard
- Returns a connection object with methods for sending and receiving messages
This approach offers several benefits:
- Automatic load balancing across multiple servers
- Simplified connection management
- Built-in retry logic for reliability
- Room creation on demand
Shard Lifecycle Notes
World tracks each shard with:
roomId: the logical room served by the shard;worldId: the world that owns the shard;url: the shard connection target returned to clients;currentConnections: the latest reported connection count;maxConnections: the configured shard capacity;status:active,maintenance, ordraining;lastHeartbeat: the latest stats update timestamp.
The built-in balancing strategies only consider active shards with available capacity (currentConnections < maxConnections):
round-robin: rotates through available shards;least-connections: picks the available shard with the lowest reported connection count;random: picks a random available shard.
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.
Shard stats are updated when connections change and through periodic forced heartbeats. Inactive shards are removed after the world cleanup timeout.
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.
Current limitations:
- Draining does not migrate connected clients; it waits for them to disconnect naturally.
- The world registry is held in room state; use deployment-specific persistence if your topology requires an external global registry.
Packet Interception
You can implement the interceptorPacket method in your room to inspect and modify packets before they're sent to users:
class GameRoom {
// Intercept packets before they're sent to users
async interceptorPacket(user: Player, packet: any, conn: Party.Connection) {
// Modify the packet based on user-specific logic
if (user.role === 'spectator') {
delete modifiedPacket.secretData;
return modifiedPacket;
}
// Return null to prevent the packet from being sent to this user
if (user.isBlocked) {
return null;
}
// Return the packet as is or with modifications
return packet;
}
}The interceptorPacket method allows you to:
- Modify packets on a per-user basis before they're sent
- Return a modified packet to change what the user receives
- Return
nullto prevent the packet from being sent to that user - Implement user-specific filtering or censoring of data
Lifecycle Hooks
Rooms provide several lifecycle hooks:
class GameRoom {
async onJoin(player: Player, conn: Connection, ctx: ConnectionContext) {}
async onLeave(player: Player, conn: Connection) {}
}Server Methods
The server provides several methods to help you manage your room:
import { RoomMethods } from "@signe/room";
export class GameRoom {
action(name: string, data: any) {
this.$send(conn, {
type: 'action',
name,
data
})
}
broadcast(name: string, data: any) {
this.$broadcast({
type: 'action',
name,
data
})
}
}
export interface GameRoom extends RoomMethods {}
## Party.Connection
Wraps a standard WebSocket, with a few additional PartyKit-specific properties.
```ts
connection.send("Good-bye!");
connection.close();https://docs.partykit.io/reference/partyserver-api/#partyconnection
Node.js adapter
@signe/room/node runs a room server in a standard single-process Node.js
application. It is useful for local development, self-hosting, Express/Fastify
style integrations, Vite dev servers, and tests that do not need PartyKit.
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { Action, Request, Room, Server } from "@signe/room";
import { createMemoryNodeRoomStorage, createNodeRoomTransport } from "@signe/room/node";
import { signal } from "@signe/reactive";
import { sync } from "@signe/sync";
@Room({ path: "demo" })
class CounterRoom {
@sync() count = signal(0);
@Action("increment")
increment(_user: unknown, value: { amount?: number }) {
this.count.update((count) => count + (value.amount ?? 1));
}
@Request({ path: "/count" })
getCount() {
return { count: this.count() };
}
}
class CounterServer extends Server {
rooms = [CounterRoom];
}
const storage = createMemoryNodeRoomStorage();
const transport = createNodeRoomTransport(CounterServer, {
partiesPath: "/parties/main",
storage,
});
const server = createServer((req, res) => {
void transport.handleNodeRequest(req, res);
});
const wsServer = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
transport.handleUpgrade(wsServer, request, socket, head);
});
server.listen(3000);HTTP requests use the same PartyKit-style room path:
curl http://localhost:3000/parties/main/demo/countWebSocket clients connect to the room URL and send normal action packets:
const socket = new WebSocket("ws://localhost:3000/parties/main/demo");
socket.send(JSON.stringify({
action: "increment",
value: { amount: 1 }
}));For middleware frameworks, pass a next callback. Requests that do not match
the configured parties path are delegated to next.
app.use((req, res, next) => {
void transport.handleNodeRequest(req, res, next);
});Room-to-room requests are available through room.context.parties:
const response = await this.room.context.parties.main
.get("other-room")
.fetch("/count");The Node adapter stores room state in memory by default. The package also provides explicit memory and SQLite storage providers.
Use createMemoryNodeRoomStorage() when you want to keep a reference to the
memory backend, inspect it, clear it, or save a snapshot for a later process
restart.
const storage = createMemoryNodeRoomStorage();
const transport = createNodeRoomTransport(CounterServer, {
storage,
});
const snapshot = storage.snapshot();
const restoredStorage = createMemoryNodeRoomStorage({ snapshot });Use createSqliteNodeRoomStorage() when you want room storage persisted in a
SQLite database. This helper uses Node's built-in node:sqlite module.
import {
createNodeRoomTransport,
createSqliteNodeRoomStorage,
} from "@signe/room/node";
const transport = createNodeRoomTransport(CounterServer, {
storage: createSqliteNodeRoomStorage({
databasePath: "./rooms.sqlite",
}),
});The SQLite helper enables PRAGMA busy_timeout = 5000 and PRAGMA journal_mode
= WAL by default to make development servers more tolerant of short-lived
write contention. You can override those defaults with busyTimeoutMs,
journalMode, and busyRetries.
Room state is stored as incremental state: entries. When a persisted delete is
encountered, the server compacts the room state by materializing the current
snapshot and removing durable delete markers. This keeps long-running SQLite
storage from accumulating "$delete" tombstones after objects or users are
removed.
To create your own storage backend, implement the key-value methods used by
@signe/room: get, put, delete, and list, then return it from a storage
provider.
import type { NodeRoomStorage, NodeRoomStorageProvider } from "@signe/room/node";
class MyStorage implements NodeRoomStorage {
async get<T = unknown>(key: string): Promise<T | undefined> {
// Read from your database
}
async put<T = unknown>(key: string, value: T): Promise<void> {
// Write to your database
}
async delete(key: string): Promise<void | boolean> {
// Delete from your database
}
async list<T = unknown>(): Promise<Map<string, T>> {
// Return all key/value entries for the room
}
}
const storage: NodeRoomStorageProvider = {
getStorage(namespace, roomId) {
return new MyStorage(namespace, roomId);
},
};
const transport = createNodeRoomTransport(CounterServer, {
storage,
});To create your own Node transport integration, use the low-level methods exposed
by createNodeRoomTransport():
transport.fetch(requestOrPath, init?)for runtimes using WebRequest/Response;transport.handleNodeRequest(req, res, next?)for Node HTTP middleware;transport.handleUpgrade(wsServer, request, socket, head)forwsWebSocket upgrades;transport.acceptWebSocket(webSocket, request)when your framework already accepted the WebSocket and you only need to attach it to a room.
The first Node adapter version targets single-process Node.js only; clustering, multi-process coordination, Cloudflare Durable Objects, Bun WebSocket, and uWebSockets.js support are outside this adapter.
See packages/room/examples/node for a runnable HTTP + WebSocket example.
Testing
import { test, vi } from "vitest"
import { testRoom, Room, Action, sync } from "@signe/room"
import { signal } from "@signe/reactive"
test('test', async () => {
@Room({
path: "game"
})
class GameRoom {
@sync() count = signal(0);
@Action('increment')
increment() {
this.count.update(c => c + 1)
}
}
const { createClient, room, server } = await testRoom(GameRoom)
const client1 = await createClient()
const client2 = await createClient()
const countFn = vi.fn()
client1.addEventListener('message', countFn)
client2.addEventListener('message',countFn)
await client1.send({
action: 'increment'
})
expect(countFn).toHaveBeenCalledTimes(2)
expect(countFn).toHaveBeenCalledWith('{"type":"sync","value":{"count":1}}')
expect(room.count()).toBe(1)
expect(server.roomStorage.get('.')).toEqual({
count: 1
})
})License
MIT
