// file: channels.mdx # Channels Mesh lets clients subscribe to Redis pub/sub channels over WebSocket. Useful for chat, notifications, dashboards, and more. ## Server: expose channels Allow clients to subscribe to specific channels or patterns: ```ts server.exposeChannel("notifications:global"); server.exposeChannel(/^chat:.+$/); // restrict access per connection server.exposeChannel(/^private:chat:.+$/, async (conn, channel) => { const meta = await server.connectionManager.getMetadata(conn); return meta?.isPremium === true; }); ``` ## Server: publish messages Send messages to all subscribers. Optionally store recent history: ```ts // publish with no history await server.writeChannel( "notifications:global", JSON.stringify({ alert: "Red alert!", }) ); // publish with history (keeps last 50) await server.writeChannel( "chat:room1", JSON.stringify({ type: "user-message", user: "1", text: "Hi", }), 50 ); ``` History is stored in Redis under `"mesh:history:"{:js}` and trimmed to the specified size. ## Server: enable persistence For long-term storage beyond Redis, enable persistence for specific channels: ```ts // Enable persistence for all chat channels server.enableChannelPersistence(/^chat:.+$/); // With custom options server.enableChannelPersistence("notifications:global", { historyLimit: 1000, }); ``` Persistence stores messages in a durable backend (SQLite by default, with support for Postgres), surviving server restarts. ## Client: subscribe ```ts const { success, history } = await client.subscribeChannel( "chat:room1", (message) => { console.log("Live message:", message); }, { historyLimit: 3 } ); console.log("History:", history); // ["msg3", "msg2", "msg1"] ``` ## Client: unsubscribe ```ts await client.unsubscribeChannel("chat:room1"); ``` ## Use cases - Chat systems - Notification feeds - Live dashboards - Cross-server pub/sub --- See [Server SDK → Channels](/server/channels) and [Client SDK → Channels](/client/channels) for full API details. // file: client/channels.mdx # Client: Channels Subscribe to real-time messages published by the server to named channels. Supports optional history on initial join. ## Subscribing to a channel ```ts const { success, history } = await client.subscribeChannel( "news:global", (message) => { console.log("Live message:", message); } ); if (!success) { console.error("Subscription failed (guarded?)"); } ``` The first argument is the channel name. The second is a callback for incoming messages. ## With message history You can optionally request recent messages when subscribing: ```ts const { success, history } = await client.subscribeChannel( "events:dashboard", (message) => { console.log("Live update:", message); }, { historyLimit: 5 } ); history.forEach((msg) => { const parsed = JSON.parse(msg); renderHistoricalEvent(parsed); }); ``` History is only available if the server explicitly stores it: `writeChannel(, , historyLimit){:js}`. ## With message history since a specific point **If persistence is enabled for a channel**, you can request messages since a specific timestamp or message ID: ```ts // Get messages since a specific timestamp (milliseconds since epoch) const { success, history } = await client.subscribeChannel( "events:dashboard", (message) => { console.log("Live update:", message); }, { historyLimit: 100, since: 1682450400000, } ); // Or get messages since a specific message ID const { success, history } = await client.subscribeChannel( "events:dashboard", (message) => { console.log("Live update:", message); }, { historyLimit: 100, since: "message-id-123", } ); ``` The `since{:js}` parameter requires persistence to be enabled for the channel on the server using `enableChannelPersistence(){:js}`. ## Retrieving channel history without subscribing You can retrieve historical messages from a channel without subscribing to it: ```ts // Get the most recent messages const { success, history } = await client.getChannelHistory( "events:dashboard", { limit: 50, } ); // Get messages since a specific timestamp const { success, history } = await client.getChannelHistory( "events:dashboard", { limit: 50, since: 1682450400000, } ); // Get messages since a specific message ID const { success, history } = await client.getChannelHistory( "events:dashboard", { limit: 50, since: "message-id-123", } ); // Process the historical messages history.forEach((msg) => { const parsed = JSON.parse(msg); renderHistoricalEvent(parsed); }); ``` ## Unsubscribing To stop receiving updates: ```ts await client.unsubscribeChannel("news:global"); ``` ## Message format Channel messages are always sent as **raw strings**. If your server publishes structured data (e.g. `JSON.stringify(...){:js}`), you must parse it yourself: ```ts client.subscribeChannel("alerts", (msg) => { try { const data = JSON.parse(msg); switch (data.type) { case "alert": showAlert(data.message); break; case "announcement": renderBanner(data.content); break; default: console.warn("Unknown message type:", data); } } catch (e) { console.error("Failed to parse channel message:", e); } }); ``` ## Use cases - **Notifications** - Alerts, banners, user-specific pushes - **Live dashboards** - Server stats, performance metrics, monitoring feeds - **Broadcasting** - Build logs, event updates, task progress - **Chat applications** - Message history, pagination, catching up on missed messages - **Activity feeds** - Loading historical activities and receiving real-time updates For presence indicators, typing status, and ephemeral per-user state, use [presence](/client/presence) instead. ## Best practices - **Unsubscribe when done** - Frees resources and avoids duplicate subscriptions - **Parse carefully** - Messages are strings: use `try/catch{:js}` when parsing JSON - **Resubscribe on reconnect** - Keep a list of active channels and restore them after connection loss - **Use pagination for large history** - For channels with extensive history, use `getChannelHistory{:js}` with the `since{:js}` parameter to implement pagination - **Consider timestamp format** - When using `since{:js}` with a timestamp, use milliseconds since epoch (e.g., `Date.now(){:js}`) - **Error handling** - Always check the `success{:js}` flag when retrieving history, as persistence might not be enabled for all channels // file: client/collections.mdx # Client: Collections When subscribing to a collection, the client receives all collection changes (additions, removals, and record updates) through a single `onDiff{:js}` handler. This is useful for filtered lists, dashboards, multiplayer rooms, etc. ## Subscribing to a collection Use `subscribeCollection(...){:js}` to subscribe: ```ts await client.subscribeCollection("collection:all-tasks", { onDiff: ({ added, removed, changed, version }) => {}, }); ``` ## Handling all collection changes The `onDiff{:js}` callback is triggered for all collection changes with three arrays: ```ts onDiff: ({ added, removed, changed, version }) => { // added: {id: string, record: any}[] - records added to the collection // removed: {id: string, record: any}[] - records removed from the collection // changed: {id: string, record: any}[] - records updated within the collection (always exactly 1 item) // version: number - the new version number of the collection added.forEach(({ id, record }) => { myLocalRecords.set(id, record); }); removed.forEach(({ id }) => { myLocalRecords.delete(id); }); changed.forEach(({ id, record }) => { myLocalRecords.set(id, record); }); } ``` ## Change consistency The `changed{:js}` array will always contain exactly one record when a record update occurs, maintaining consistency with the `added{:js}` and `removed{:js}` arrays which can contain multiple items during bulk operations. ## Record updates within collections Collections always provide **full record data** for all changes. Collections do not support patch mode - they always send complete record objects for simplicity. ```ts await client.subscribeCollection("collection:all-tasks", { onDiff: ({ added, removed, changed, version }) => { // All arrays contain full record objects console.log("Added records:", added); console.log("Removed records:", removed); console.log("Changed records:", changed); }, }); ``` ## Version tracking and resyncing Internally, Mesh uses version tracking. If your client misses a version (e.g. network drop), Mesh automatically resyncs the full list. ## Unsubscribing When done, unsubscribe: ```ts await client.unsubscribeCollection("collection:all-tasks"); ``` This stops receiving diffs and updates for that collection. ## Reconnection Collections are automatically resubscribed on reconnect, including handlers. ## Notes - All changes flow through a single `onDiff{:js}` callback for consistency - You may still want to subscribe to individual records if you need patch-based updates - The `changed{:js}` array always contains exactly one item when triggered - **Consistent array shapes**: All arrays (`added{:js}`, `removed{:js}`, `changed{:js}`) use the same `{ id, record }{:js}` structure for consistency ## Use cases - **Live filtered lists**: completed vs active, by tag or assignee - **Per-user dashboards**: synced queries scoped to auth context - **Multiplayer rooms**: users in room, players in game - **Workspace views**: synced list of active docs or projects - **Real-time notifications**: unified handling of all list changes // file: client/commands.mdx # Client: Commands Use `client.command(...){:js}` to send a request to the server and receive a response. This is a core mechanism for client-server interaction. ## Sending commands ```ts const result = await client.command("echo", "Hello!"); console.log(result); // "echo: Hello!" const sum = await client.command("math:add", { a: 5, b: 3 }); console.log(sum); // 8 ``` The method returns a `Promise{:js}` that resolves with the response or rejects if the server throws or times out. ## Error handling If the server throws an error or a guard fails, the client receives it: ```ts try { const result = await client.command("divide", { a: 10, b: 0 }); } catch (err) { console.error(err.message); // e.g. "Cannot divide by zero" console.error(err.code); // e.g. "ESERVER" or your custom code } ``` You can inspect `.message{:js}`, `.code{:js}`, and `.name{:js}` on the error object. ## Timeouts The third argument to `client.command(...){:js}` is a timeout in milliseconds (not an object): ```ts await client.command("slow-op", {}, 5000); // 5s timeout ``` If the command takes too long to respond, it rejects with a `CodeError{:js}`: ```ts { message: "Command timed out after 5000ms.", code: "ETIMEOUT", name: "TimeoutError" } ``` ## Best practices - Wrap command calls in `try/catch{:js}` - Use descriptive command names like `"user:update"{:js}` or `"chat:send"{:js}` - Handle command timeouts explicitly - Validate inputs on the client before sending // file: client/index.mdx # Client: Setup & Configuration `MeshClient{:js}` manages the WebSocket connection to a `MeshServer{:js}`. It handles subscriptions, reconnects (with automatic resubscription), ping/pong heartbeat monitoring, round-trip latency tracking, and command dispatching. ## Basic usage ```ts const client = new MeshClient("ws://localhost:8080"); await client.connect(); const res = await client.command("echo", "Hello!"); console.log(res); // "echo: Hello!" ``` ## Configuration options The second argument to `MeshClient{:js}` is optional and lets you control reconnect behavior and liveness settings: ```ts const client = new MeshClient("ws://localhost:8080", { shouldReconnect: true, // default: true reconnectInterval: 2000, // ms between attempts (default: 2000) maxReconnectAttempts: Infinity, pingTimeout: 30000, // ms to wait for pings (default: 30000) maxMissedPings: 1, // allowed missed pings before reconnecting }); ``` These options should match the server's configured `pingInterval{:js}` and `maxMissedPongs{:js}` values to avoid premature disconnects. ## Connect / Disconnect You must call `connect(){:js}` manually to start the WebSocket connection. ```ts const client = new MeshClient("ws://localhost:8080"); await client.connect(); // Connect await client.close(); // Disconnect (and disable auto-reconnect) ``` ## Events The client emits events for connection state: ```ts client.on("message", (e) => { /* .. */ }); client.on("connect", () => { console.log("Connected"); }); client.on("close", () => { console.log("Connection closed"); }); client.on("disconnect", () => { console.log("Disconnected"); }); client.on("reconnect", () => { console.log("Reconnected"); }); client.on("reconnectfailed", () => { console.log("Gave up trying to reconnect"); }); client.on("ping", () => { console.log("Ping"); }); client.on("latency", (ms) => { console.log(`Latency: ${ms}ms`); }); ``` ## Reconnection behavior If `shouldReconnect{:js}` is enabled and a ping is missed: - The client disconnects - Mesh attempts to reconnect - On success, emits `"reconnect"{:js}` - On failure (after `maxReconnectAttempts{:js}`), emits `"reconnectfailed"{:js}` ## Connection status Check current status via: ```ts if (client.status === Status.ONLINE) { console.log("Connected"); } ``` ## Command timeout By default, `client.command(...){:js}` times out after 30s. You can override per-call: ```ts const result = await client.command("echo", "hi", 10_000); // 10s timeout ``` --- Next steps: - [Commands](/client/commands) - [Rooms](/client/room) - [Presence](/client/presence) - [Channels](/client/channels) - [Records](/client/records) // file: client/metadata.mdx # Client Metadata Mesh provides two types of metadata storage: **Connection Metadata** and **Room Metadata**. Both are stored in Redis and accessible across all server instances. This page explains how to work with metadata from the client side. ## Connection Metadata ### Accessing Connection Metadata Clients can retrieve connection metadata for any connection by providing the connection ID: ```ts const metadata = await client.getConnectionMetadata("conn123"); console.log(metadata.userId, metadata.username); ``` To fetch metadata for the **current** connection, call `getConnectionMetadata{:js}` without any arguments: ```ts const metadata = await client.getConnectionMetadata(); console.log(metadata.userId, metadata.username); ``` ### Setting Connection Metadata Clients can directly set metadata for their own connection using the built-in `setConnectionMetadata{:js}` method: ```ts // Replace entire metadata (default behavior) await client.setConnectionMetadata({ status: "online", preferences: { theme: "dark" } }); ``` #### Update Strategies The `setConnectionMetadata{:js}` method supports three different update strategies: **Replace Strategy (default):** ```ts // Replaces the entire metadata object await client.setConnectionMetadata( { status: "away" }, { strategy: "replace" } ); ``` **Merge Strategy:** ```ts // Merges with existing metadata at the top level await client.setConnectionMetadata( { status: "away" }, { strategy: "merge" } ); // Existing: { user: "john", preferences: { theme: "dark" } } // Result: { user: "john", status: "away", preferences: { theme: "dark" } } ``` **Deep Merge Strategy:** ```ts // Recursively merges nested objects await client.setConnectionMetadata( { preferences: { notifications: false } }, { strategy: "deepMerge" } ); // Existing: { user: "john", preferences: { theme: "dark", lang: "en" } } // Result: { user: "john", preferences: { theme: "dark", lang: "en", notifications: false } } ``` #### Server-Controlled Metadata Pattern For scenarios requiring server validation, you can still use custom commands: ```ts // Server-side server.exposeCommand("update-user-status", async (ctx) => { const { status } = ctx.payload; // Server validation logic here if (!["online", "away", "busy"].includes(status)) { throw new Error("Invalid status"); } // Use merge strategy to preserve other metadata await server.connectionManager.setMetadata(ctx.connection, { status, lastUpdate: Date.now() }, { strategy: "merge" } ); return { success: true }; }); ``` ```ts // Client-side await client.command("update-user-status", { status: "away" }); ``` ## Room Metadata ### Accessing Room Metadata You can fetch metadata for any room: ```ts const metadata = await client.getRoomMetadata("lobby"); console.log(metadata.topic, metadata.maxUsers); ``` ### Setting Room Metadata Room metadata can only be set server-side. Use custom commands to expose controlled updates. The server-side room metadata API supports the same strategies as connection metadata: ```ts // Server-side server.exposeCommand("update-room-topic", async (ctx) => { const { roomName, topic } = ctx.payload; // Validate permissions here if (!await isRoomModerator(ctx.connection, roomName)) { throw new Error("Insufficient permissions"); } await server.roomManager.setMetadata(roomName, { topic, lastModified: Date.now() }, { strategy: "merge" }); return { success: true }; }); ``` ```ts // Client-side await client.command("update-room-topic", { roomName: "lobby", topic: "Welcome to the lobby!" }); ``` ## Metadata with Presence Metadata is especially powerful when combined with presence updates. Here's how to show user information alongside presence events: ```ts const { success, present } = await client.subscribePresence( "lobby", async (update) => { const metadata = await client.getConnectionMetadata(update.connectionId); if (update.type === "join") { console.log(`${metadata.username} joined the room`); addUserToUI(metadata); } else if (update.type === "leave") { console.log(`${metadata.username} left the room`); removeUserFromUI(metadata.userId); } } ); // Load initial user list with metadata if (success) { const allMetadata = await Promise.all( present.map((connId) => client.getConnectionMetadata(connId)) ); allMetadata.forEach(addUserToUI); } ``` ## Metadata Change Notifications Mesh doesn't automatically push metadata updates to clients. If you need live metadata updates, implement them using **channels** or **records**: ### Using Channels ```ts // Server-side: Notify when user metadata changes server.exposeCommand("update-user-metadata", async (ctx) => { const { metadata } = ctx.payload; // Update the metadata await server.connectionManager.setMetadata(ctx.connection, metadata, { strategy: "merge" }); // Broadcast the change const rooms = await server.roomManager.getRoomsForConnection(ctx.connection.id); for (const room of rooms) { await server.writeChannel( `room:${room}:metadata-updates`, JSON.stringify({ connectionId: ctx.connection.id, metadata: await server.connectionManager.getMetadata(ctx.connection) }) ); } return { success: true }; }); // Client-side: Listen for metadata changes await client.subscribeChannel("room:lobby:metadata-updates", (message) => { const { connectionId, metadata } = JSON.parse(message); updateUserInUI(connectionId, metadata); }); ``` ### Using Records ```ts // Server-side: Store user metadata as a record server.exposeCommand("update-user-metadata", async (ctx) => { const { metadata } = ctx.payload; // Update both connection metadata and a record await server.connectionManager.setMetadata(ctx.connection, metadata, { strategy: "merge" }); await server.writeRecord( `user:${ctx.connection.id}:metadata`, metadata, { strategy: "merge" } ); return { success: true }; }); // Client-side: Subscribe to metadata record await client.subscribeRecord("user:conn123:metadata", (update) => { updateUserInUI("conn123", update.full); }); ``` ## Best Practices 1. **Use descriptive metadata structure:** ```ts const metadata = { user: { id: "123", username: "john", avatar: "/avatars/john.png" }, session: { status: "online", lastSeen: Date.now() }, preferences: { theme: "dark", lang: "en" } }; ``` 2. **Leverage merge strategies for partial updates:** ```ts // Client can safely update status without affecting user info await client.setConnectionMetadata( { session: { status: "away" } }, { strategy: "deepMerge" } ); ``` 3. **Keep metadata compact and serializable** - Avoid large objects or functions 4. **Use metadata for UI state, not application logic** - Critical data should be stored in your database 5. **Consider metadata lifecycle** - Connection metadata is automatically cleaned up when connections close // file: client/presence.mdx # Client: Presence Track who's online in a room, listen for join/leave events, and share ephemeral presence state (like `"typing"{:js}` or `"away"{:js}`). Need user-level presence across tabs or devices? Use the [createPresence utility](/client/unified) to group connections by user ID or any other logic. ## Subscribing Subscribe to presence in a room: ```ts const { success, present } = await client.subscribePresence( "lobby", (update) => { if (update.type === "join") { console.log("User joined:", update.connectionId); } else if (update.type === "leave") { console.log("User left:", update.connectionId); } else if (update.type === "state") { console.log("State update:", update.connectionId, update.state); } } ); ``` You'll receive: - A list of currently present members with metadata via `present{:js}` - Real-time `"join"{:js}`, `"leave"{:js}`, and `"state"{:js}` events The `present{:js}` array includes both connection IDs and their metadata: ```ts // present structure: [ { id: "conn_123", metadata: { username: "alice", avatar: "..." } }, { id: "conn_456", metadata: { username: "bob", avatar: "..." } } ] ``` Unsubscribe when done: ```ts await client.unsubscribePresence("lobby"); ``` ## Join + subscribe in one step Use `joinRoom(){:js}` with a callback to automatically subscribe to presence: ```ts const { success, present } = await client.joinRoom("lobby", (update) => { console.log(update); }); ``` Both `subscribePresence(){:js}` and `joinRoom(){:js}` return the same `present{:js}` format with metadata included. If you omit the callback from `joinRoom(){:js}`, you still get the current occupants with metadata - but you won't be subscribed to real-time updates. ## Leaving rooms When you leave a room, any active presence subscription for that room is automatically cleaned up: ```ts await client.leaveRoom("lobby"); // unsubscribes from presence ``` You don't need to manually call `unsubscribePresence(){:js}` after leaving a room. ## Publishing presence state Send ephemeral presence state to others in the room: ```ts await client.publishPresenceState("lobby", { state: { status: "typing" }, expireAfter: 8000, // optional (ms) }); ``` Clear it manually: ```ts await client.clearPresenceState("lobby"); ``` ## Automatic presence refresh When the server sends a ping and the client responds with a pong, the server refreshes the presence TTL for all rooms that the client connection has joined. You don't need to manually track rooms or re-send updates for each room your client has joined. ## Receiving presence states Presence updates include state events: ```ts const { success, present, states } = await client.subscribePresence( "lobby", (update) => { if (update.type === "state") { console.log(`${update.connectionId} state:`, update.state); } } ); ``` You'll also get a `states{:js}` object with current values at the time of subscription: ```ts { "abc123": { status: "typing" }, "def456": { status: "away" } } ``` ## Working with member metadata Both methods return member information including metadata: ```ts const { success, present } = await client.subscribePresence("lobby", callback); // or const { success, present } = await client.joinRoom("lobby"); // Display current members present.forEach(member => { console.log(`${member.metadata?.username || member.id} is online`); }); ``` For real-time updates in your presence callback, resolve metadata for new connections: ```ts await client.subscribePresence("lobby", async (update) => { if (update.type === "join") { const metadata = await client.getConnectionMetadata(update.connectionId); console.log(`${metadata?.username || update.connectionId} joined`); } }); ``` ## Use cases - Online indicators with user names and avatars - Room occupancy lists - Typing indicators - Game states like "ready", "spectating", etc. ## Tips - Both `joinRoom(){:js}` and `subscribePresence(){:js}` include member metadata automatically - Unsubscribe when no longer needed to reduce overhead - Resubscribe after reconnecting (or use `createPresence{:js}`, which handles this for you) - Use `expireAfter{:js}` for short-lived states like `"typing"{:js}` - Debounce frequent updates to avoid spamming the network // file: client/reconnection.mdx # Reconnection ## Activity tracking Mesh includes intelligent activity tracking that: 1. Monitors input activity through mouse, keyboard, and touch events 2. Detects when a tab becomes inactive 3. Tries to determine if the connection might have been affected by browser throttling 4. Automatically forces a reconnection when necessary 5. Ensures all subscriptions are properly restored This is particularly useful for handling browser behavior where tabs that go inactive have their JavaScript execution throttled, which can cause WebSocket connections to appear active from the client's perspective but be considered dead by the server. ## Configuration You can customize the reconnection behavior when creating a client: ```ts const client = new MeshClient("ws://localhost:8080", { shouldReconnect: true, // Enable auto-reconnection (default: true) reconnectInterval: 2000, // ms between reconnection attempts (default: 2000) maxReconnectAttempts: 5, // give up after 5 tries (default: Infinity) pingTimeout: 30000, // ms to wait for ping before considering connection dead (default: 30000) maxMissedPings: 1, // number of missed pings before reconnecting (default: 1) }); ``` ## Subscription restoration When a client reconnects after a disconnection or tab inactivity, the following are restored: 1. **Room memberships** - All rooms the client had joined 2. **Room presence subscriptions** - All presence callbacks for rooms 3. **Channel subscriptions** - All channel subscriptions with their callbacks and history limits 4. **Record subscriptions** - All record subscriptions with their callbacks and modes 5. **Collection subscriptions** - All collection subscriptions with their callbacks // file: client/records.mdx # Client Records Mesh supports subscribing to individual records stored in Redis. When a record changes, clients receive either the full value or a JSON patch describing the update depending on the selected mode. This page explains how to subscribe to and update records on the client side. ## Subscribing to Records Use the `subscribeRecord{:js}` method to subscribe to a record: ```ts let userProfile = {}; const { success, record, version } = await client.subscribeRecord( "user:123", ({ recordId, full, version }) => { userProfile = update.full; console.log( `Received full update for ${update.recordId} v${update.version}:`, update.full ); } ); if (success) { userProfile = record; console.log("Initial record:", record); console.log("Initial version:", version); } ``` The first parameter is the record ID, and the second parameter is a callback function that will be called whenever the record is updated. By default, the client receives the entire updated record every time (`{ mode: "full" }{:js}`). This is simpler to use and ideal for small records or when patching isn't needed. ## Subscribing in Patch Mode For larger records or when bandwidth is a concern, you can use `{ mode: "patch" }{:js}`: ```ts let productData = {}; const { success, record, version } = await client.subscribeRecord( "product:456", ({ recordId, version, patch, full }) => { if (patch) { // normally you'll receive `patch`, but if the client falls out of sync, // the server will send a full update instead to resynchronize. applyPatch(productData, patch); console.log(`Applied patch for ${recordId} v${version}`); } else { productData = full; console.log( `Received full (resync) for ${recordId} v${version}` ); } }, { mode: "patch" } ); if (success) { productData = record; } ``` In `{ mode: "patch" }{:js}`, the client receives only changes as JSON patches and must apply them locally. This is especially useful for large records that only change in small ways over time. Patch mode only works for records that are **objects or arrays**. Primitive values like strings, numbers, or booleans aren't representable as JSON patches, so you should use full mode for those cases. ## Unsubscribing from Records When you no longer need to receive updates for a record, you should unsubscribe: ```ts await client.unsubscribeRecord("user:123"); ``` This will stop the client from receiving updates and clean up any resources associated with the subscription. ## Updating Records If a record has been exposed as writable via `exposeWritableRecord{:js}` on the server, clients can publish updates using the `writeRecord{:js}` method: ```ts const userId = "123"; const success = await client.writeRecord(`cursor:user:${userId}`, { x: 100, y: 250, timestamp: Date.now(), }); if (success) { console.log("Cursor position updated successfully."); } else { console.error("Failed to update cursor position (maybe permission denied?)."); } ``` The method takes two parameters: 1. The record ID 2. The new value for the record The method returns `true{:js}` if the server accepted the write, and `false{:js}` if it was rejected (e.g., due to a failed guard). ## Handling Self-Updates When a client publishes an update to a record using `writeRecord{:js}`, it will also receive that update through its subscription callback, just like any other client. This ensures consistency and avoids special-casing updates based on origin. If your app logic already applies local updates optimistically, you may choose to ignore redundant self-updates in your callback: ```ts // Track the last update we sent let lastSentUpdate = null; // Subscribe to the record await client.subscribeRecord("cursor:user:123", (update) => { // Check if this is our own update if ( lastSentUpdate && JSON.stringify(update.full) === JSON.stringify(lastSentUpdate) ) { console.log("Ignoring echo of our own update"); lastSentUpdate = null; // Reset after handling return; } // Handle the update normally console.log("Received update:", update.full); }); // When sending an update const newPosition = { x: 100, y: 250, timestamp: Date.now() }; lastSentUpdate = newPosition; await client.writeRecord("cursor:user:123", newPosition); ``` ## Versioning and Resync Every update includes a `version{:js}`. Clients track the current version and, in patch mode, expects that `version === localVersion + 1{:js}`. If a gap is detected (missed patch), the client will automatically unsubscribe and resubscribe to the record to fetch the current full state. During resubscription, your callback will receive the full current record data as if you had just subscribed for the first time. ## Handling Reconnection When a client reconnects after a disconnection, it automatically resubscribes to all active subscriptions. You **do not need** to manually track or restore subscriptions. The client does this internally. The client automatically maintains subscriptions for: - Records - Collections - Channels - Presence/Rooms All subscriptions are restored with their original callbacks and options when the connection is reestablished. ## Use Cases Records are particularly useful for: 1. **User profiles** - Store user information that needs to be synchronized across clients - Update profile fields in real-time 2. **Collaborative editing** - Shared cursors and selection ranges - Document state synchronization 3. **Game state** - Player positions - Game board state - Inventory and status 4. **Dashboard configuration** - Widget layouts - User preferences - Real-time metrics ## Best Practices 1. **Choose the right mode** - Use `{ mode: "full" }{:js}` mode for small records - Use `{ mode: "patch" }{:js}` mode for large records or when bandwidth is a concern 2. **Handle reconnection** - Track active subscriptions - Resubscribe after reconnection 3. **Consider optimistic updates** - Apply updates locally before sending to the server - Handle conflicts if the server rejects the update 4. **Unsubscribe when done** - Always unsubscribe from records when they're no longer needed - This reduces unnecessary network traffic and server load ## Record Persistence The server can be configured to persist records to a durable storage backend (like SQLite), which means: 1. **Automatic restoration** - When the server restarts, persisted records are automatically restored from storage 2. **Historical data preservation** - Important record data survives server restarts and Redis failures 3. **Seamless client experience** - Clients don't need to handle persistence themselves; they'll receive the restored data automatically upon subscription This is particularly useful for: - **User profiles** that should be preserved between sessions - **Game states** that need to survive server restarts - **Document content** that requires long-term storage - **Application settings** that should persist across deployments From a client perspective, persistence is completely transparent - you interact with records the same way whether they're persisted or not. The server handles all the storage and restoration logic automatically. // file: client/rooms.mdx # Client: Rooms Rooms group connections together. Use them to organize users, broadcast messages, or track who is present in a shared context. ## Joining a room Use `client.joinRoom(...){:js}` to join a room. This also supports optional presence tracking: ```ts // Just join const { success, present } = await client.joinRoom("lobby"); if (success) { console.log("Current members:", present); } ``` ```ts // Join and receive presence updates const { success, present } = await client.joinRoom("lobby", (update) => { if (update.type === "join") { console.log("User joined:", update.connectionId); } else if (update.type === "leave") { console.log("User left:", update.connectionId); } }); ``` The `present{:js}` array always reflects current members in the room at time of join, even if you don't subscribe to presence updates. ## Leaving a room Use `client.leaveRoom(...){:js}` to exit: ```ts await client.leaveRoom("lobby"); ``` If you joined with presence tracking, it will be automatically unsubscribed. ## Use cases - Shared chat rooms - Multiplayer game lobbies - Collaborative workspaces - Audio/video channels Rooms are lightweight, real-time groupings. Combine with presence for full user visibility. // file: commands.mdx # Commands Commands are structured request/response messages - like RPC over WebSockets. Clients send a named command with a payload, and the server responds. Use commands for things like auth, data fetching, or state updates. ## Server: expose a command ```ts server.exposeCommand("echo", async (ctx) => { return `echo: ${ctx.payload}`; }); ``` ## Client: send a command ```ts const response = await client.command("echo", "Hello!"); console.log(response); // "echo: Hello!" ``` ## Features - Built-in request/response - Async/await or sync API - Structured error handling - Middleware support (validation, auth, etc.) - Context includes payload, connection, and metadata --- See [Server SDK → Commands](/server/commands) and [Client SDK → Commands](/client/commands) for full usage details. // file: metadata.mdx # Metadata Mesh provides two types of metadata, both stored in Redis and available across all server instances: - **Connection metadata**: tied to individual WebSocket connections - **Room metadata**: tied to specific room names Metadata is useful for attaching information like user IDs, roles, tokens, room settings, or custom fields relevant to your app. --- ## Connection metadata Attach structured data to a WebSocket connection, commonly used for: - User identification (e.g. `userId{:js}`, `role{:js}`) - Session tokens - Enriching presence events ```ts await server.connectionManager.setMetadata(connection, { userId: "user123", role: "admin", }); ``` ### Update strategies Connection metadata supports three update strategies: ```ts // Replace entire metadata (default) await server.connectionManager.setMetadata(connection, { userId: "user123", role: "admin", }); // Merge with existing metadata at the top level await server.connectionManager.setMetadata(connection, { role: "moderator", // This will update the role while preserving userId }, { strategy: "merge" }); // Deep merge - recursively merges nested objects await server.connectionManager.setMetadata(connection, { preferences: { theme: "dark" }, // This will merge with existing preferences }, { strategy: "deepMerge" }); ``` You can read metadata later: ```ts const meta = await server.connectionManager.getMetadata(connection); // or by connection ID: const meta = await server.connectionManager.getMetadata("conn123"); ``` Connection metadata is automatically removed when the connection closes. --- ## Room metadata Store structured info about a room, like: - Topics or settings - Ownership and access rules - Game or session state ```ts await server.roomManager.setMetadata("lobby", { topic: "General Discussion", createdBy: "user123", }); ``` ### Update strategies Room metadata also supports three update strategies: ```ts // Replace entire metadata (default) await server.roomManager.setMetadata("lobby", { topic: "General Discussion", createdBy: "user123", }); // Merge with existing metadata at the top level await server.roomManager.setMetadata("lobby", { topic: "Updated Topic", // This preserves createdBy while updating topic }, { strategy: "merge" }); // Deep merge - recursively merges nested objects await server.roomManager.setMetadata("lobby", { settings: { maxUsers: 100 // This merges with existing settings object } }, { strategy: "deepMerge" }); ``` To retrieve metadata: ```ts const roomMeta = await server.roomManager.getMetadata("lobby"); ``` Room metadata is removed automatically when you call `deleteRoom(...){:js}`, or you can explicitly clear it by setting the value to `null{:js}`. --- ## Accessing metadata from clients Clients can retrieve metadata directly using built-in client methods: ### Connection metadata ```ts const meta = await client.getConnectionMetadata("conn123"); console.log(meta.userId, meta.username); ``` Get your own connection metadata by omitting the connection ID: ```ts const meta = await client.getConnectionMetadata(); console.log(meta.userId, meta.username); ``` ### Room metadata ```ts const roomMeta = await client.getRoomMetadata("lobby"); console.log(roomMeta.topic); ``` --- ## Best practices - Keep metadata small. Avoid large or deeply nested objects. - Don't store sensitive data. Expose only what's safe to read. - Use `merge{:js}` strategy for partial updates when you want to preserve existing fields. - Use `deepMerge{:js}` strategy when working with nested objects that need partial updates. - Use consistent ID formats, e.g. `"lobby"{:js}`, `"user:123"{:js}`. --- See [Server SDK → Metadata](/server/metadata) and [Client SDK → Presence](/client/presence) for advanced usage. // file: presence.mdx # Presence Mesh tracks which connections are currently present in each room (if presence tracking is enabled for the room), and notifies subscribed clients when others join or leave. Presence (and presence state) is tracked **per connection**, not per user. When a user opens your app in multiple tabs or devices, each creates its own WebSocket connection. These connections are treated independently in Mesh, each with its own: - Connection ID - Room membership - Per-room presence status - Optional presence state Even if those connections share the same `userId{:js}`, Mesh does **not** deduplicate them. This is intentional-Mesh gives you flexible primitives without enforcing identity rules or session merging. To implement user-level presence (e.g. "only show one typing indicator per user"): 1. Store a `userId{:js}` in connection metadata 2. On the client, call `getConnectionMetadata(connectionId){:js}` 3. Deduplicate by `userId{:js}` in your UI For a ready-made solution, use the [Unified Presence utility](/client/unified) which handles this pattern elegantly. See [Server SDK → Presence](/server/presence#presence-is-per-connection-not-per-user) for details. --- ## Basic usage ### Server: enable presence tracking ```ts // Enable tracking for a single room server.trackPresence("lobby"); // Or for all matching rooms server.trackPresence(/^.+$/); ``` ### Client: subscribe to presence ```ts const { success, present } = await client.subscribePresence( "lobby", (update) => { if (update.type === "join") { console.log("Joined:", update.connectionId); } else if (update.type === "leave") { console.log("Left:", update.connectionId); } } ); ``` You'll get a list of currently present connections and live updates as others join or leave. **Alternative: join room and get presence with metadata** ```ts const { success, present } = await client.joinRoom("lobby", (update) => { // same callback structure }); // present includes metadata for each member: // [{ id: "conn_123", metadata: { username: "alice" } }, ...] ``` Choose `subscribePresence(){:js}` for connection IDs only, or `joinRoom(){:js}` with a callback to get connection IDs plus metadata in one call. Unsubscribe when no longer needed: ```ts await client.unsubscribePresence("lobby"); ``` --- ## Presence states Clients can publish transient status info like `"typing"{:js}`, `"away"{:js}`, or anything your app defines. ```ts await client.publishPresenceState("lobby", { state: { status: "typing" }, expireAfter: 8000, // optional (ms) }); ``` These states: - Are scoped to a specific room + connection - Automatically expire (if `expireAfter{:js}` is set) - Are cleared when the connection leaves the room or disconnects Clients subscribed to presence in that room will receive a `"state"{:js}` update: ```ts { type: "state", connectionId: "abc123", state: { status: "typing" } // or null if cleared/expired } ``` --- ## Summary Presence is ideal for: - **Who's online** indicators - **Live room rosters** - **Typing/activity signals** - **Lightweight awareness in collaborative apps** --- See [Server SDK → Presence](/server/presence) and [Client SDK → Presence](/client/presence) for full APIs and advanced behavior. // file: records.mdx # Records Records are versioned JSON documents stored in Redis. Clients can subscribe to them and receive real-time updates. Each record has: - A **unique ID** - A current **value** - A version number - Optional **patches** for efficient updates ## Server: expose records ```ts // Expose a specific record server.exposeRecord("user:123"); // Expose records matching a pattern server.exposeRecord(/^product:\d+$/); // With access control server.exposeRecord(/^private:.+$/, async (conn, id) => { const meta = await server.connectionManager.getMetadata(conn); return !!meta?.userId; }); ``` ## Server: writable records ```ts // Allow clients to update records server.exposeWritableRecord(/^cursor:user:\d+$/); // With write permission check server.exposeWritableRecord(/^profile:user:\d+$/, async (conn, id) => { const meta = await server.connectionManager.getMetadata(conn); return meta?.userId === id.split(":").pop(); }); ``` ## Server: update records ```ts // Update a record (broadcasts to all subscribers) await server.writeRecord("user:123", { name: "Alice", status: "active", }); ``` ## Client: subscribe ```ts // Full mode (default) const { success, record, version } = await client.subscribeRecord( "user:123", (update) => { console.log(`Received update for ${update.recordId}:`, update.full); } ); // Patch mode (more efficient for large or frequently changing records) let productData = {}; await client.subscribeRecord( "product:456", (update) => { if (update.patch) { applyPatch(productData, update.patch); } else { // The full update is received if the client falls out of sync productData = update.full; } }, { mode: "patch" } ); ``` ## Client: update records ```ts // Update a writable record const success = await client.writeRecord("cursor:user:123", { x: 100, y: 250, timestamp: Date.now(), }); ``` ## Modes Clients choose how to receive updates: - **Full mode**: entire value sent every time - **Patch mode**: only JSON diffs are sent (smaller, more efficient) Patch mode only works for objects and arrays - _**not** primitive values_. ## Versioning Mesh tracks versions automatically. If a client misses an update or falls out of sync, it receives a full resync automatically. ## Features - Distributed state synchronization via Redis - Automatic versioning and conflict resolution - Efficient updates with JSON patches - Access control via guards - Client-side write capability (optional) - Automatic resync after disconnection - No client-side diffing required - Optional persistence to durable storage ## Use cases Records can be used for both ephemeral and persistent data: **Ephemeral data** (in-memory only): - Collaborative editing (cursors, selections) - Temporary game state (player positions) - Form state sync - Real-time indicators **Persistent data** (with persistence enabled): - User profiles and settings - Game save states - Document content - Application configuration --- See [Server → Records](/server/records) and [Client → Records](/client/records) for more details on writable updates, guards, subscriptions, and reconnect handling. // file: rooms.mdx # Rooms Rooms are logical groups of connections. Clients can join and leave rooms, and servers can broadcast messages or track membership (i.e. presence). Membership is tracked in Redis and cleaned up automatically when a connection closes. All server instances are accounted for in this cleanup process. ## Client: join a room ```ts // Join a room const { success, present } = await client.joinRoom("lobby"); // Join with presence updates const { success, present } = await client.joinRoom("lobby", (update) => { if (update.type === "join") { console.log("User joined:", update.connectionId); } else if (update.type === "leave") { console.log("User left:", update.connectionId); } }); ``` ## Server: manage rooms ```ts // Add a connection to a room await server.addToRoom("lobby", connection); // Get members in a room const members = await server.getRoomMembers("lobby"); // Remove from a room await server.removeFromRoom("lobby", connection); ``` ## Access control Protect rooms with middleware: ```ts server.useMiddleware(async (ctx) => { if (ctx.command === "mesh/join-room") { const { roomName } = ctx.payload; // Check if user can join this room if (roomName.startsWith("private:") && !isAuthorized(ctx)) { throw new Error("Access denied"); } } }); ``` ## Features - Distributed membership tracking via Redis - Automatic cleanup on disconnect - Real-time presence updates (optional) - Access control via middleware - Broadcast messaging to room members - Works across multiple server instances --- - See [Server SDK → Rooms](/server/rooms) and [Client SDK → Rooms](/client/rooms) for full details. - See [Presence](/presence) for more on tracking users in rooms. // file: server/channels.mdx # Server: Channels Let clients subscribe to Redis pub/sub channels over WebSocket. Useful for chat, notifications, dashboards, and more. ## Expose channels Clients can only subscribe to channels you've explicitly exposed: ```ts // Exact match server.exposeChannel("notifications:global"); // Pattern match server.exposeChannel(/^chat:.+$/); // With a guard server.exposeChannel(/^private:chat:.+$/, async (conn, channel) => { const meta = await server.connectionManager.getMetadata(conn); return meta?.isPremium === true; }); ``` Guards run per-subscription and can block or allow access. ## Publish messages Use `writeChannel(...){:js}` to broadcast a message: ```ts await server.writeChannel( "chat:room1", JSON.stringify({ type: "message", userId: "123", text: "Hello!", }) ); ``` ## Optional message history You can store recent messages in Redis by passing a third argument: ```ts await server.writeChannel( "chat:room1", JSON.stringify({ type: "message", userId: "123", text: "Hello!", }), 50 ); // Keep last 50 messages ``` These are stored under `"mesh:history:"{:js}` and sent to clients that request `historyLimit{:js}`. ## Persistent message storage For long-term message storage beyond Redis's in-memory history, you can enable persistence for channels: ```ts // Enable persistence for all chat channels server.enableChannelPersistence(/^chat:.+$/); // Enable persistence with custom options server.enableChannelPersistence("notifications:global", { historyLimit: 1000, flushInterval: 1000, // 1 second }); ``` Persistence stores messages in a durable backend and can be configured with these options: - `historyLimit{:js}`: Maximum number of messages to retain per channel (default: 50) - `flushInterval{:js}`: How often to flush buffered messages to storage in ms (default: 500) - `maxBufferSize{:js}`: Maximum messages to buffer before forcing a flush (default: 100) - `filter{:js}`: Function to determine which messages to persist (default: all messages) - `adapter{:js}`: Custom persistence adapter (default: SQLite) When persistence is enabled, messages published to matching channels are automatically stored and can be retrieved later, even after server restarts. This lets you selectively persist data for channels where message history matters, like support conversations, audit logs, or direct messaging without storing transient or high-frequency messages such as notification pings, system heartbeats, or ephemeral alerts. By default, the persistence layer uses SQLite with an in-memory database (`":memory:"{:js}`), which means data is lost when the server restarts. ## Configuring persistence storage To ensure your channel messages truly persist across server restarts, configure a database in your server options. Mesh supports both SQLite and PostgreSQL adapters: ### SQLite (default) ```ts const server = new MeshServer({ port: 3000, redisOptions: { /* your Redis options */ }, persistenceOptions: { filename: "./data/channels.db", }, }); server.enableChannelPersistence(/^chat:.+$/); ``` ### PostgreSQL ```ts const server = new MeshServer({ port: 3000, redisOptions: { /* your Redis options */ }, persistenceAdapter: "postgres", persistenceOptions: { host: "localhost", port: 5432, database: "mesh_db", user: "mesh_user", password: "mesh_password", // Optional: use connection string instead // connectionString: "postgresql://mesh_user:mesh_password@localhost:5432/mesh_db" }, }); server.enableChannelPersistence(/^chat:.+$/); ``` ## Relationship between Redis history and persistence Redis history (via the third parameter to `writeChannel{:js}`) and persistence (via `enableChannelPersistence{:js}`) operate independently but complement each other: ```ts // Enable persistence with a 1000 message limit server.enableChannelPersistence("chat:room1", { historyLimit: 1000 }); // Publish with a 50 message Redis history await server.writeChannel("chat:room1", message, 50); ``` In this example: 1. The message is stored in Redis with a 50 message limit (for immediate history) 2. The same message is also stored in the persistence database with a 1000 message limit (for long-term storage) When clients request history: - Requests with only `historyLimit{:js}` get messages from Redis (fast, recent messages) - Requests with both `historyLimit{:js}` and `since{:js}` parameter try to get messages from the persistence layer first, falling back to Redis if needed This dual-storage approach provides both fast access to recent messages (Redis) and longer-term storage that survives restarts (persistence layer). ## Notes - Messages must be strings. Serialize objects with `JSON.stringify(...){:js}`. - Use colons (`":"{:js}`) in channel names for structure (e.g. `"chat:room:123"{:js}`). This is just a convention. Mesh doesn't require or enforce it, but it helps keep things organized when matching with regex. ## Use cases - **Chat**: public or private channels, typing indicators - **Notifications**: global alerts, user-specific updates - **Dashboards**: live system metrics, stock tickers - **Broadcasts**: server-to-client or cross-instance messaging ## Tips - Define consistent message shapes (e.g. `{ type, userId, timestamp, ... }{:js}`) - Use guards to control who can subscribe - Use history sparingly and cap it to reasonable sizes - For important data that needs to survive restarts, enable persistence - Consider using different persistence settings for different channel types - Use the `filter{:js}` option to selectively persist only important messages // file: server/collections.mdx # Server: Collections Collections let you define and expose dynamic **groups of records**. These groups update automatically when underlying records change, and clients receive **diffs** of added or removed members. This is ideal for collaborative apps, dashboards, filtered views, and pagination. ## Expose collections To make a collection subscribable, use `exposeCollection(...){:js}`: ```ts server.exposeCollection("collection:all-tasks", async () => { return await server.listRecordsMatching("task:*"); }); ``` `listRecordsMatching{:js}` is a function that takes a Redis glob pattern, and an optional callback (more on that below), and returns the records that match the pattern. For example: ```ts await server.writeRecord("task:1", { taskid: 1 }); await server.writeRecord("task:2", { taskid: 2 }); await server.writeRecord("task:3", { taskid: 3 }); const records = await server.listRecordsMatching("task:*"); console.log(records); ``` The above would log: ```json [ { taskid: 1, id: 'task:1' }, { taskid: 2, id: 'task:2' }, { taskid: 3, id: 'task:3' } ] ``` The second argument is a **resolver** that **must return an array of records** (not just IDs) for the given collection. The resolver runs: - Immediately when a client subscribes - Again any time a record changes (added, removed, or updated) Collections can use dynamic patterns too: ```ts server.exposeCollection( /^collection:user:(\d+):tasks$/, async (conn: Connection, collectionId: string) => { const userId = collectionId.split(":")[2]; return await server.listRecordsMatching(`user:${userId}:task:*`); } ); ``` ## Pagination example You can implement pagination by encoding page numbers in collection names, and making use of the `slice{:js}`, `sort{:js}`, and `map{:js}` options: ```ts server.exposeCollection( /^tasks:page:\d+$/, async (conn: Connection, collectionId: string) => { const pageNum = parseInt(collectionId.split(":")[2]); const pageSize = 10; return await server.listRecordsMatching("task:*", { // Transform records for the client (optional - exclude heavy fields) map: (record) => ({ id: record.id, title: record.title, status: record.status, created_at: record.created_at }), // Sort by created_at descending (newest first) sort: (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), // Paginate based on extracted page number slice: { start: (pageNum - 1) * pageSize, count: pageSize }, }); } ); ``` **Client-side pagination:** Client-side pagination works by subscribing to a single page at a time: ```ts // Subscribe to page 1 await client.subscribeCollection("tasks:page:1", { onDiff: ({ added, removed, changed }) => { // } }); // Unsubscribe from page 1, then subscribe to page 2 await client.unsubscribeCollection("tasks:page:1"); await client.subscribeCollection("tasks:page:2"); ``` ## Per-connection isolation The resolver runs for **each subscribed connection individually**, which enables: - **Connection-specific authorization** - each user sees only what they should - **Different views of the same collection** - based on user permissions/context - **Proper isolation** - each connection maintains its own version and state ```ts server.exposeCollection("collection:user-tasks", async (conn: Connection, collectionId: string) => { // Each connection gets its own resolver call const userId = conn.metadata?.userId; const userRole = conn.metadata?.role; if (userRole === 'admin') { // Admins see all tasks return await server.listRecordsMatching("task:*"); } else { // Regular users see only their own tasks return await server.listRecordsMatching(`user:${userId}:task:*`); } }); ``` ## Record changes trigger diffs Whenever a record is added, removed, or updated, Mesh will: 1. Rerun the resolver for each subscribed connection 2. Compare new and old record IDs for each connection 3. Send a diff to clients where membership changed **Diff format:** - `added{:js}`: Array of full record objects that were added to the collection - `removed{:js}`: Array of full record objects that were removed from the collection - `changed{:js}`: Array of full record objects that were changed in the collection ## Resync logic Mesh tracks a `version{:js}` number per collection per connection, starting at version 1. If a client receives a diff with a skipped version (e.g. expected v3, got v5), it auto-resubscribes and fetches the full list again. This ensures correctness across reconnects, network drops, or inter-instance propagation delays. ## Flexible attribute-based filtering You can expose collections that allow clients to filter records by arbitrary attributes, using a pattern that encodes the filter as part of the collection name. This is useful for building flexible search, filter, or view features. For example, to let clients subscribe to filtered lists of products: ```ts server.exposeCollection( /^collection:products:filter:.+$/, async (conn: Connection, collectionId: string) => { const [, , , encodedQuery] = collectionId.split(":"); const filters = JSON.parse(decodeURIComponent(encodedQuery)); const products = await server.listRecordsMatching("product:*"); return products.filter(product => Object.entries(filters).every(([key, value]) => product[key] === value) ); } ); ``` On the client, you encode your filter as a JSON object, then subscribe to the filtered collection: ```ts const query = encodeURIComponent(JSON.stringify({ category: "books", inStock: true })); client.subscribeCollection(`collection:products:filter:${query}`); ``` This pattern is flexible and can be extended to support multi-field filters, value arrays, partial matching, and more. For example, you could filter by `{ brand: "Acme", priceRange: [10, 50] }{:js}` or any other combination of product attributes. ## Use cases - **Task lists**: real-time filters across shared boards - **Projects**: dynamic membership as records are created/deleted - **Views**: multi-tenant views per user, team, or org - **Pagination**: sort and slice large datasets into pages // file: server/commands.mdx # Server: Commands Expose structured request/response handlers that clients can call over WebSocket. ## Basic usage ```ts server.exposeCommand("echo", async (ctx) => { return `echo: ${ctx.payload}`; }); ``` Clients can call this command with: ```ts const response = await client.command("echo", "Hello!"); console.log(response); // "echo: Hello!" ``` Command handlers receive an object of `type MeshContext{:js}` with the following properties: - `ctx.command{:js}`: command name - `ctx.payload{:js}`: data sent by the client - `ctx.connection{:js}`: the WebSocket connection - Additional fields added by middleware You can return any JSON-serializable value. ```ts server.exposeCommand("add", (ctx) => { return ctx.payload.a + ctx.payload.b; }); ``` ## Errors Throwing inside a command sends the error back to the client in a predictable format. ```ts server.exposeCommand("divide", (ctx) => { const { a, b } = ctx.payload; if (b === 0) throw new Error("Cannot divide by zero"); return a / b; }); ``` The client receives: ```ts { error: "Cannot divide by zero", code: "ESERVER", name: "Error" } ``` To customize the error code and name: ```ts server.exposeCommand("login", () => { throw new CodeError("Invalid credentials", "EAUTH", "AuthError"); }); ``` Client receives: ```ts { error: "Invalid credentials", code: "EAUTH", name: "AuthError" } ``` Or if you prefer, you can return an object with `error{:js}`, `code{:js}` and `name{:js}` properties instead of throwing anything. It's recommended to stick to this error format for the sake of consistency. ## Middleware Attach middleware to validate input, enforce auth, or inject data: ```ts const validate = (ctx) => { if (!ctx.payload.email.includes("@")) throw new Error("Invalid email"); }; server.exposeCommand("update-profile", async (ctx) => { return { success: true }; }, [validate]); ``` See [middleware](/server/middleware) for more details. ## Built-in commands Mesh includes built-in commands for rooms, presence, channels, metadata, and records: - `"mesh/join-room"{:js}`, `"mesh/leave-room"{:js}` - `"mesh/subscribe-presence"{:js}`, `"mesh/unsubscribe-presence"{:js}` - `"mesh/publish-presence-state"{:js}`, `"mesh/clear-presence-state"{:js}` - `"mesh/subscribe-channel"{:js}`, `"mesh/unsubscribe-channel"{:js}` - `"mesh/get-connection-metadata"{:js}`, `"mesh/get-my-connection-metadata"{:js}` - `"mesh/get-room-metadata"{:js}` - `"mesh/subscribe-record"{:js}`, `"mesh/unsubscribe-record"{:js}` - `"mesh/publish-record-update"{:js}` These power the SDK's features and can be intercepted with middleware if needed. For example, you could: - Prevent access to certain rooms based on user roles - Validate presence state schema before publishing - Enforce metadata structure for all connections - Restrict record updates to certain users - Control access to channels based on user permissions - Validate record update schema with middleware ## Tips - Use namespaced commands like `"chat:send"{:js}` or `"user:create"{:js}` - Validate inputs with middleware - Keep handlers simple and focused - Return only what the client needs // file: server/index.mdx # Server Setup & Configuration Set up a Mesh server with just a few lines of code. ## Basic usage ```ts const server = new MeshServer({ port: 8080, redisOptions: { host: "localhost", port: 6379 }, }); server.exposeCommand("echo", async (ctx) => { return `echo: ${ctx.payload}`; }); ``` The server starts listening immediately. To be sure it's up, you can: `await server.ready(){:js}`. ## Configuration options ```ts const server = new MeshServer({ port: 8080, host: "0.0.0.0", // default path: "/mesh", // WebSocket path (default "/") redisOptions: { host: "localhost", port: 6379, password: "optional", }, pingInterval: 30_000, latencyInterval: 5_000, maxMissedPongs: 1, enablePresenceExpirationEvents: true, }); ``` `redisOptions{:js}` are passed directly to [ioredis](https://github.com/redis/ioredis). You can use any valid ioredis client option here. ## Handling connections ```ts server.onConnection(async (conn) => { console.log("Connected:", conn.id); await server.connectionManager.setMetadata(conn, { connectedAt: Date.now(), }); }); server.onDisconnection(async (conn) => { console.log("Disconnected:", conn.id); }); ```` ## Graceful shutdown ```ts process.on("SIGINT", async () => { console.log("Shutting down..."); await server.close(); process.exit(0); }); ``` ## Using with Express To integrate with an existing Express + HTTP server: ```ts const app = express(); const server = app.listen(3000, () => {}); const mesh = new MeshServer({ server, // configure as usual }); ``` ## Next steps - [Expose commands](/server/commands) - [Set up channels](/server/channels) - [Configure rooms](/server/room) - [Track presence](/server/presence) - [Expose records](/server/records) - [Add middleware](/server/middleware) // file: server/latency.mdx # Server: Latency & Liveness Mesh uses an application-level ping/pong system to track connection health, measure latency, and ensure stale connections are cleaned up across instances. This works independently of WebSocket protocol-level ping frames and gives you: - Accurate round-trip latency tracking - Configurable liveness timeouts - Automatic cleanup of dead connections and their state (rooms, presence, etc.) ## Configuration You can configure ping/liveness behavior via the server constructor: ```ts const server = new MeshServer({ redisOptions: { host: "localhost", port: 6379 }, pingInterval: 30_000, // how often to ping clients latencyInterval: 5_000, // how often to request latency from clients maxMissedPongs: 1, // allowed missed pongs before disconnecting }); ``` With the default `maxMissedPongs = 1{:js}`, a client has about `2 * pingInterval{:js}` to respond before being disconnected. ## What happens under the hood Every connection: 1. Sends a `"ping"{:js}` command every `pingInterval{:js}` - If no `"pong"{:js}` is received, it's marked as inactive - If `missedPongs > maxMissedPongs{:js}`, the connection is closed 2. Sends a `"latency:request"{:js}` every `latencyInterval{:js}` - When the client responds with `"latency:response"{:js}`, Mesh calculates round-trip time and can emit latency stats 3. On receiving `"pong"{:js}`, the connection is marked as alive and its presence TTL is refreshed Presence TTL is only refreshed when a valid `"pong"{:js}` is received, meaning that missed pongs will eventually cause Redis to expire presence entries and trigger a `"leave"{:js}` event. ## Why Mesh doesn't use native WebSocket pings - Native pings don't go through your app layer, so you can't measure latency - Native pings don't support multi-instance cleanup - Native pings can't update Redis TTLs for presence tracking Mesh solves all of that with application-level control. ## Monitoring latency (optional) Mesh automatically tracks round-trip latency. If you want to observe it on the client: ```ts client.on("latency", (ms) => { console.log("Latency:", ms, "ms"); }); ``` This is useful for: - Displaying latency to users (e.g. in a status bar) - Logging latency over time for diagnostics - Detecting network slowdowns There's no server API for observing latency - it's purely client-side and emitted as `"latency"{:js}` events. --- See [Client SDK → Latency & Reconnect](/client/latency) for configuration on the client side. // file: server/metadata.mdx # Server: Metadata Mesh supports two types of metadata: - **Connection metadata**: data attached to individual WebSocket connections - **Room metadata**: data associated with named rooms All metadata is stored in Redis and accessible across all server instances. ## Connection metadata Useful for identifying users, storing roles, or tracking per-connection session info. ### Set metadata ```ts await server.connectionManager.setMetadata(connection, { userId: "user123", role: "admin", lastActive: Date.now(), }); ``` #### Update Strategies Connection metadata supports the same update strategies as client metadata: ```ts // Replace entire metadata (default) await server.connectionManager.setMetadata(connection, newMetadata); // Merge with existing metadata await server.connectionManager.setMetadata(connection, partialMetadata, { strategy: "merge" }); // Deep merge nested objects await server.connectionManager.setMetadata(connection, nestedUpdate, { strategy: "deepMerge" }); ``` ### Get metadata #### Single connection ```ts const metadata = await server.connectionManager.getMetadata(connection); ``` The `getMetadata{:js}` method requires a `Connection{:js}` object, not a connection ID string. To get metadata by connection ID, first retrieve the connection object using `server.connectionManager.getLocalConnection(connectionId){:js}`. #### All connections Returns an array of connection objects with id and metadata: ```ts const all = await server.connectionManager.getAllMetadata(); // [ // { id: "conn1", metadata: { userId: "user123", role: "admin" } }, // { id: "conn2", metadata: { userId: "user456", role: "user" } }, // ... // ] ``` #### All connections in a room Returns an array of connection objects with id and metadata: ```ts const members = await server.connectionManager.getAllMetadataForRoom("lobby"); // [ // { id: "conn1", metadata: { userId: "user123", username: "Alice" } }, // { id: "conn2", metadata: { userId: "user456", username: "Bob" } }, // ] ``` ### Update metadata To update existing metadata while preserving other fields, use the merge strategies: ```ts // Merge approach - preserves existing fields await server.connectionManager.setMetadata(connection, { lastActive: Date.now(), }, { strategy: "merge" }); // Manual merge approach (if you need custom logic) const existing = await server.connectionManager.getMetadata(connection); await server.connectionManager.setMetadata(connection, { ...existing, lastActive: Date.now(), }); ``` ### Cleanup Connection metadata is automatically removed when the connection disconnects. This happens during the connection cleanup process in `server.cleanupConnection(){:js}`. ## Room metadata Useful for storing room-level settings, topic info, or ownership metadata. ### Set metadata ```ts await server.roomManager.setMetadata("lobby", { topic: "General Discussion", createdBy: "user123", maxUsers: 10, createdAt: Date.now(), }); ``` This completely replaces any existing metadata for the room. #### Update Strategies Room metadata supports the same update strategies as connection metadata: ```ts // Replace entire metadata (default) await server.roomManager.setMetadata("lobby", newMetadata, { strategy: "replace" }); // Merge with existing metadata await server.roomManager.setMetadata("lobby", partialMetadata, { strategy: "merge" }); // Deep merge nested objects await server.roomManager.setMetadata("lobby", nestedUpdate, { strategy: "deepMerge" }); ``` ### Get metadata #### Single room ```ts const metadata = await server.roomManager.getMetadata("lobby"); // { topic: "General Discussion", createdBy: "user123", maxUsers: 10 } ``` #### All rooms Returns an array of room objects with id and metadata: ```ts const all = await server.roomManager.getAllMetadata(); // [ // { id: "lobby", metadata: { topic: "General Discussion", createdBy: "user123" } }, // { id: "dev", metadata: { topic: "Development Chat", createdBy: "user456" } } // ] ``` ### Delete metadata To completely remove room metadata, you have two options: **Option 1: Delete the room entirely (recommended)** ```ts await server.deleteRoom(roomName); ``` This removes all occupants AND deletes the room metadata permanently. **Option 2: Manual metadata deletion** ```ts await server.redisManager.redis.del(`mesh:roommeta:${roomName}`); ``` This only deletes the metadata but leaves the room structure intact. **Room cleanup methods:** - `server.clearRoom(roomName){:js}` - Removes all occupants but **preserves** room metadata - `server.deleteRoom(roomName){:js}` - Removes all occupants and **deletes** room metadata permanently Setting metadata to `null{:js}` with `setMetadata(roomName, null){:js}` stores the JSON string `"null"{:js}` rather than deleting the metadata. Use `deleteRoom(){:js}` for complete room deletion. ## Exposing metadata to clients ### Connection metadata Clients can access their own metadata, or the metadata of other connections: ```ts // get metadata for some connection by ID const metadata = await client.getConnectionMetadata("conn123"); // get my metadata const myMetadata = await client.getConnectionMetadata(); ``` ### Room metadata ```ts // get room metadata const metadata = await client.getRoomMetadata("lobby"); ``` Room metadata cannot be set directly by clients. Use custom commands for controlled updates: ```ts // server server.exposeCommand("update-room-topic", async (ctx) => { const { roomName, topic } = ctx.payload; // validation logic if (!await isRoomModerator(ctx.connection, roomName)) { throw new Error("Insufficient permissions"); } await server.roomManager.setMetadata(roomName, { topic, lastModified: Date.now(), modifiedBy: ctx.connection.id }, { strategy: "merge" }); return { success: true }; }); // client await client.command("update-room-topic", { roomName: "lobby", topic: "New Topic" }); ``` ## Useful patterns ### Connection metadata with authentication ```ts // Set initial metadata during connection server.onConnection(async (connection) => { // Extract user info from connection request const userId = await authenticateConnection(connection); await server.connectionManager.setMetadata(connection, { userId, authenticatedAt: Date.now(), permissions: await getUserPermissions(userId) }); }); // Update metadata during session server.exposeCommand("update-user-status", async (ctx) => { const { status } = ctx.payload; await server.connectionManager.setMetadata(ctx.connection, { status, lastStatusUpdate: Date.now() }, { strategy: "merge" }); return { success: true }; }); ``` ### Room metadata with lifecycle management ```ts // Create room with metadata server.exposeCommand("create-room", async (ctx) => { const { roomName, roomType, maxUsers } = ctx.payload; await server.roomManager.setMetadata(roomName, { type: roomType, maxUsers, createdBy: ctx.connection.id, createdAt: Date.now(), memberCount: 0 }); return { success: true }; }); // Update member count when users join/leave server.onConnection(async (connection) => { connection.on("mesh/join-room", async (roomName) => { const metadata = await server.roomManager.getMetadata(roomName); if (metadata) { await server.roomManager.setMetadata(roomName, { memberCount: (metadata.memberCount || 0) + 1 }, { strategy: "merge" }); } }); // similarly handle "mesh/leave-room" }); ``` ### Metadata-based filtering ```ts // Get all public rooms with space available const allRooms = await server.roomManager.getAllMetadata(); const availableRooms = allRooms.filter(room => room.metadata.type === "public" && room.metadata.memberCount < room.metadata.maxUsers ); // Get all rooms created by a specific user const userRooms = allRooms.filter(room => room.metadata.createdBy === "user123" ); // Complex filtering with multiple criteria const premiumPublicRooms = allRooms .filter(room => room.metadata.type === "public") .filter(room => room.metadata.premium === true) .sort((a, b) => b.metadata.memberCount - a.metadata.memberCount); // Connection filtering examples const allConnections = await server.connectionManager.getAllMetadata(); const activeAdmins = allConnections .filter(conn => conn.metadata.role === "admin") .filter(conn => conn.metadata.status === "online"); ``` ## Best practices 1. **Use consistent data structures**: ```ts const connectionMetadata = { user: { id: "123", username: "alice" }, session: { status: "online", joinedAt: Date.now() }, permissions: ["read", "write"] }; const roomMetadata = { config: { type: "public", maxUsers: 50 }, stats: { memberCount: 25, messagesCount: 1532 }, moderation: { createdBy: "user123", moderators: ["user456"] } }; ``` 2. **Don't store sensitive data**: Metadata may be exposed to clients, so avoid passwords, tokens, or PII 3. **Use merge strategies for partial updates**: - `"replace"{:js}` for complete resets - `"merge"{:js}` for updating top-level fields - `"deepMerge"{:js}` for updating nested objects 4. **Implement cleanup patterns**: Consider TTL for temporary metadata or cleanup jobs for stale data 5. **Consider metadata as cache**: Don't rely on metadata for critical data that must persist - use your database as the source of truth // file: server/middleware.mdx # Server: Middleware Middleware functions run before command handlers. Use them for auth, validation, logging, or injecting context. ## Global middleware Run before every command: ```ts server.useMiddleware(async (ctx) => { const meta = await server.connectionManager.getMetadata(ctx.connection); if (!meta?.userId) throw new Error("Unauthorized"); ctx.user = { id: meta.userId, role: meta.role, }; }); ``` ## Per-command middleware Attach middleware to individual commands. These run after global middleware: ```ts const validate = async (ctx) => { const { email } = ctx.payload; if (!email?.includes("@")) throw new Error("Invalid email"); }; server.exposeCommand( "register", async (ctx) => { // ctx.payload is already validated return { success: true }; }, [validate] ); ``` Middleware can be useful for things like throttling client messages: ```ts const lastSent = new Map(); server.useMiddleware(async (ctx) => { if (ctx.command !== "cursor-update") return; const now = Date.now(); const last = lastSent.get(ctx.connection.id) || 0; if (now - last < 50) { throw new Error("Too fast"); // or just return } lastSent.set(ctx.connection.id, now); }); ``` ## Middleware order 1. Global middleware (in the order registered) 2. Per-command middleware 3. Command handler If any middleware throws, the error is sent back to the client. ## Modifying context You can attach custom fields to `ctx{:js}` for downstream use: ```ts server.useMiddleware(async (ctx) => { const meta = await server.connectionManager.getMetadata(ctx.connection); ctx.user = { id: meta?.userId, permissions: await fetchPermissions(meta?.userId), }; }); server.exposeCommand("admin:action", async (ctx) => { if (!ctx.user?.permissions.includes("admin")) { throw new Error("Admin required"); } return { success: true }; }); ``` ## Client error response If a middleware throws, the client receives an error: ```ts { error: "Admin required", code: "ESERVER", name: "Error" } ``` ## Use cases These are mostly pseudocode, but hopefully help illustrate what this is useful for: **Auth** ```ts server.useMiddleware(async (ctx) => { const meta = await server.connectionManager.getMetadata(ctx.connection); if (!meta?.userId) throw new Error("Login required"); }); ``` **Validation** ```ts const requireTitle = (ctx) => { if (!ctx.payload?.title) throw new Error("Missing title"); }; server.exposeCommand("post:create", handler, [requireTitle]); ``` **Logging** ```ts server.useMiddleware((ctx) => { console.log(`[cmd] ${ctx.command} from ${ctx.connection.id}`); }); ``` **Rate limiting** ```ts const limiter = createRateLimiter(10, 60_000); // 10 req/min server.useMiddleware((ctx) => { if (!limiter.allow(ctx.connection.id)) throw new Error("Rate limit exceeded"); }); ``` // file: server/presence.mdx # Server: Presence Enable real-time tracking of who’s in a room. Mesh uses Redis TTLs and keyspace notifications to automatically clean up stale entries and notify subscribed clients. ## Enable presence tracking Track individual rooms or patterns: ```ts server.trackPresence("lobby"); server.trackPresence(/^.+$/); ``` ## With options Customize TTL or restrict visibility per connection: ```ts server.trackPresence("admin-room", { ttl: 60_000, // Presence entry expires after 60s of inactivity (default is 0, meaning no expiration) guard: async (conn, roomName) => { const meta = await server.connectionManager.getMetadata(conn); return meta?.isAdmin === true; }, }); ``` ## How it works - Presence entries are stored in Redis with optional TTL (if configured) - TTL is refreshed via ping/pong heartbeat while connection is active - Server pings client -> client pongs -> server refreshes presence TTL - On disconnect or TTL expiration, presence is cleaned up - Mesh emits `"leave"{:js}` events for both scenarios Presence expiration uses Redis keyspace notifications. You can disable this via `enablePresenceExpirationEvents: false{:js}` in your `MeshServer` config. This might be desirable if you want to implement a custom cleanup strategy, or if your Redis setup is very high traffic to the point of the overhead of processing keyspace notifications could impact performance. In most cases though, you should leave this enabled. ## Get current presence ```ts const ids = await server.presenceManager.getPresentConnections("lobby"); // ["conn123", "conn456"] ``` ## Optional: Disable auto-cleanup ```ts const server = new MeshServer({ enablePresenceExpirationEvents: false, }); ``` With this setting, you are responsible for manually cleaning up expired presence entries. ## Per-connection model Presence is tracked **per connection**, not per user. If a user opens multiple tabs or devices, each one gets a unique connection ID and separate presence state, even if all share the same `userId{:js}` in metadata. This gives you precise control and avoids assumptions about identity or sessions. To group presence by user: 1. Store `userId{:js}` in connection metadata 2. Resolve metadata by `connectionId{:js}` on the client with `client.getConnectionMetadata(connectionId){:js}` 3. Deduplicate by `userId{:js}` in your UI This enables user-level presence like “only one typing indicator per user” even with multiple sessions. ## Presence state Clients can publish ephemeral presence states (e.g. `"typing"{:js}`, `"away"{:js}`). On the server: - State is stored at `"mesh:presence:state::"{:js}` - It's removed on disconnect, leave, or expiration - States can have TTLs via `expireAfter{:js}` - When cleared, Mesh emits `{ type: "state", state: null }{:js}` to subscribers ## Enrich presence with metadata ```ts server.onConnection(async (conn) => { await server.connectionManager.setMetadata(conn, { userId: "user123", username: "Alice", avatar: "https://example.com/avatar.png", }); }); ``` --- See [Client SDK → Presence](/client/presence) for subscribing and handling updates. // file: server/records.mdx # Server: Records Records are versioned JSON values stored in Redis. Clients can subscribe to them and receive updates in real time - either full values or patches. ## Expose records Clients can only subscribe to records you explicitly expose: ```ts // Specific ID server.exposeRecord("user:123"); // Pattern match server.exposeRecord(/^product:\d+$/); // With guard server.exposeRecord(/^private:.+$/, async (conn, recordId) => { const meta = await server.connectionManager.getMetadata(conn); return !!meta?.userId; }); ``` ## Writable records To allow clients to modify a record, use `exposeWritableRecord(...){:js}`: ```ts // Anyone can write to cursors server.exposeWritableRecord(/^cursor:user:\d+$/); // Only allow writing to own profile server.exposeWritableRecord(/^profile:user:\d+$/, async (conn, recordId) => { const meta = await server.connectionManager.getMetadata(conn); return meta?.userId === id.split(":").pop(); }); ``` Writable records are automatically readable. If you want to apply different access rules for reading vs writing, you can expose the same pattern using both `exposeRecord(...){:js}` and `exposeWritableRecord(...){:js}`. Only the matching guard for the operation type (read or write) will be used. This lets you allow read access to some clients while restricting who can publish updates. ## Update records Use `writeRecord(...){:js}` to update a record's value and notify subscribers: ```ts await server.writeRecord("user:123", { name: "Alice", status: "active", }); ``` This: 1. Stores the new value in Redis 2. Increments the version 3. Computes a patch 4. Broadcasts to all subscribers If the new value is identical to the old value (whether it's a patchable JSON object or a primitive value like a string, number, or boolean) the record version is not incremented and no update is sent to subscribed clients. To delete a record: ```ts await server.deleteRecord("user:123"); ``` ## Get current value ```ts const { value, version } = await server.getRecord("user:123"); ``` ## Versioning & patching Every update increments a version. Clients in `patch` mode expect sequential versions. If a version is missed, Mesh auto-resyncs the client with a full update. Clients in: - **Full mode** get the entire value - **Patch mode** get a JSON Patch to apply locally ## Use cases - **User profiles**: synced, editable fields - **Collaborative docs**: live document state - **Game state**: board state, player status - **Dashboards**: metrics and layout configs ## Tips - Use structured IDs: `"user:123"{:js}`, `"doc:456"{:js}`, `"game:abc"{:js}` - Use guards to control read/write access - Keep records small and focused - Use patch mode for large or frequently updated records ## Persistent record storage For long-term record storage beyond Redis's in-memory storage, you can enable persistence for records: ```ts // Enable persistence for user profiles server.enableRecordPersistence({ writePattern: /^profile:user:.+$/, // Matches records for runtime access control restorePattern: "profile:user:%" // SQL LIKE pattern for database restoration }); // Enable persistence with custom options server.enableRecordPersistence({ writePattern: "game:state:global", restorePattern: "game:state:global" }, { maxBufferSize: 50, flushInterval: 1000, // 1 second }); ``` ### Write vs Restore Patterns The `enableRecordPersistence()` method requires both patterns to be explicit: - **`writePattern`**: RegExp or string used at runtime to check if a record should be persisted when it's updated - **`restorePattern`**: String used as a raw SQL LIKE pattern to query the database for records to restore on server startup ### Common Pattern Examples ```ts // Persist all user data server.enableRecordPersistence({ writePattern: /^user:.+$/, restorePattern: "user:%" }); // Persist specific document types with complex matching server.enableRecordPersistence({ writePattern: /^doc:(article|post):[A-Za-z0-9_-]+$/, restorePattern: "doc:%" // Restore all docs, regardless of type }); // Persist exact match server.enableRecordPersistence({ writePattern: "app:config:main", restorePattern: "app:config:main" }); ``` Persistence can be configured with these options: - `flushInterval{:js}`: How often to flush buffered records to storage in ms (default: 500) - `maxBufferSize{:js}`: Maximum records to buffer before forcing a flush (default: 100) - `adapter{:js}`: Custom persistence adapter (default: uses server's configured adapter) When persistence is enabled, records matching the specified patterns are automatically stored and can be retrieved later, even after server restarts. This lets you selectively persist data for records where state preservation matters - like user profiles, game states, or document content - without storing transient or high-frequency records such as cursor positions or ephemeral status indicators. By default, the persistence layer uses SQLite with an in-memory database (`":memory:"{:js}`), which means data is lost when the server restarts. ## Configuring persistence storage To ensure your records truly persist across server restarts, configure a database in your server options. Mesh supports both SQLite and PostgreSQL adapters: ### SQLite (default) ```ts const server = new MeshServer({ port: 3000, redisOptions: { /* your Redis options */ }, persistenceOptions: { filename: "./data/mesh.db", }, }); server.enableRecordPersistence({ writePattern: /^profile:user:.+$/, restorePattern: "profile:user:%" }); ``` ### PostgreSQL ```ts const server = new MeshServer({ port: 3000, redisOptions: { /* your Redis options */ }, persistenceAdapter: "postgres", persistenceOptions: { host: "localhost", port: 5432, database: "mesh_db", user: "mesh_user", password: "mesh_password", // Optional: use connection string instead // connectionString: "postgresql://mesh_user:mesh_password@localhost:5432/mesh_db" }, }); server.enableRecordPersistence({ writePattern: /^profile:user:.+$/, restorePattern: "profile:user:%" }); ``` ## Relationship with Redis storage Records are always stored in Redis for immediate access, while the persistence layer provides long-term storage that survives server restarts: 1. When a record is updated, **it's stored in Redis** 2. If persistence is enabled for that record, **it's also queued for storage in the persistence database** 3. On server restart, **persisted records are automatically restored to Redis** This approach provides both fast access to current record values (Redis) and longer-term storage that survives restarts (persistence layer). // file: server/rooms.mdx # Server: Rooms Rooms let you group connections and broadcast messages to them. Mesh tracks room membership using Redis, so it works across all server instances and automatically cleans up when connections disconnect. ## Server-side helpers Use these methods to manage rooms manually or inside custom commands: ```ts server.addToRoom(roomName, connection); server.removeFromRoom(roomName, connection); server.removeFromAllRooms(connection); server.getAllRooms(); // string[] server.isInRoom(roomName, connection); // boolean server.getRoomMembers(roomName); // string[] (connection IDs) server.getRoomMembersWithMetadata(roomName); // { id: string; metadata: any }[] server.clearRoom(roomName); // removes all members server.deleteRoom(roomName); // removes all members and destroyed room metadata ``` Most apps won't need to use these APIs. The client SDK handles join/leave automatically via `client.joinRoom(...){:js}` and `client.leaveRoom(...){:js}`, which invoke the built-in `"mesh/join-room"{:js}` and `"mesh/leave-room"{:js}` commands. ## Access control The built-in `"mesh/join-room"{:js}` command can be intercepted with middleware to enforce auth or role checks: ```ts server.useMiddleware(async (ctx) => { if (ctx.command === "mesh/join-room") { const { roomName } = ctx.payload; const meta = await server.connectionManager.getMetadata(ctx.connection); if (!meta?.canJoinRooms) throw new Error("Access denied"); if (roomName.startsWith("admin:") && !meta.isAdmin) { throw new Error("Admins only"); } } }); ``` This gives you full control over who can join what. --- See [Client SDK → Rooms](/client/rooms) for how clients join and leave rooms.