From 06571ac28a4aa36205457fc1baf19480920a7c8b Mon Sep 17 00:00:00 2001 From: nvms Date: Thu, 17 Apr 2025 18:43:50 -0400 Subject: [PATCH] feature: room metadata --- packages/mesh/README.md | 33 +++++- packages/mesh/src/server/room-manager.ts | 145 +++++++++++++++++++++++ packages/mesh/src/tests/rooms.test.ts | 35 ++++++ 3 files changed, 212 insertions(+), 1 deletion(-) diff --git a/packages/mesh/README.md b/packages/mesh/README.md index 3540fce..a8d29d7 100644 --- a/packages/mesh/README.md +++ b/packages/mesh/README.md @@ -186,7 +186,7 @@ This feature is great for: ### Metadata -You'll probably encounter a scenario where you need to relate some data to a particular connection. Mesh provides a way to do this using the `setMetadata` method. This is useful for storing user IDs, tokens, or any other information you need to associate with a connection. +You can associate data like user IDs, tokens, or custom attributes with a connection using the `setMetadata` method. This metadata is stored in Redis and accessible from any server instance, making it ideal for identifying users, managing permissions, or persisting session-related data across distributed deployments. Metadata is stored in Redis, so it can be safely accessed from any instance of your server. @@ -230,6 +230,37 @@ const metadata = await server.connectionManager.getAllMetadataForRoom(roomName); // [{ [connectionId]: { userId, token } }, ...] ``` +### Room Metadata + +Similar to connection metadata, Mesh allows you to associate arbitrary data with rooms. This is useful for storing room-specific information like topics, settings, or ownership details. Room metadata is also stored in Redis and accessible across all server instances. + +```ts +// set metadata for a room +await server.roomManager.setMetadata("lobby", { + topic: "General Discussion", + maxUsers: 50, +}); + +// get metadata for a specific room +const lobbyMeta = await server.roomManager.getMetadata("lobby"); +// { topic: "General Discussion", maxUsers: 50 } + +// update metadata (merges with existing data) +await server.roomManager.updateMetadata("lobby", { + topic: "Updated Topic", // Overwrites existing topic + private: false, // Adds new field +}); + +const updatedLobbyMeta = await server.roomManager.getMetadata("lobby"); +// { topic: "Updated Topic", maxUsers: 50, private: false } + +// get metadata for all rooms +const allRoomMeta = await server.roomManager.getAllMetadata(); +// { lobby: { topic: "Updated Topic", maxUsers: 50, private: false }, otherRoom: { ... } } +``` + +Room metadata is removed when `clearRoom(roomName)` is called. + ### Command Middleware Mesh allows you to define middleware functions that run before your command handlers. This is useful for tasks like authentication, validation, logging, or modifying the context before the main command logic executes. diff --git a/packages/mesh/src/server/room-manager.ts b/packages/mesh/src/server/room-manager.ts index dd37ec1..8746825 100644 --- a/packages/mesh/src/server/room-manager.ts +++ b/packages/mesh/src/server/room-manager.ts @@ -16,10 +16,29 @@ export class RoomManager { return `connection:${connectionId}:rooms`; } + private roomMetadataKey(roomName: string) { + return `mesh:roommeta:${roomName}`; + } + + /** + * Retrieves all connection IDs associated with the specified room. + * + * @param {string} roomName - The name of the room for which to fetch connection IDs. + * @returns {Promise} A promise that resolves to an array of connection IDs in the room. + * @throws {Error} If there is an issue communicating with Redis or retrieving the data, the promise will be rejected with an error. + */ async getRoomConnectionIds(roomName: string): Promise { return this.redis.smembers(this.roomKey(roomName)); } + /** + * Checks whether a given connection (by object or ID) is a member of a specified room. + * + * @param {string} roomName - The name of the room to check for membership. + * @param {Connection | string} connection - The connection object or connection ID to check. + * @returns {Promise} A promise that resolves to true if the connection is in the room, false otherwise. + * @throws {Error} If there is an issue communicating with Redis or processing the request, the promise may be rejected with an error. + */ async connectionIsInRoom( roomName: string, connection: Connection | string @@ -29,6 +48,15 @@ export class RoomManager { return !!(await this.redis.sismember(this.roomKey(roomName), connectionId)); } + /** + * Adds a connection to a specified room, associating the connection ID with the room name + * in Redis. Supports both `Connection` objects and connection IDs as strings. + * + * @param {string} roomName - The name of the room to add the connection to. + * @param {Connection | string} connection - The connection object or connection ID to add to the room. + * @returns {Promise} A promise that resolves when the operation is complete. + * @throws {Error} If an error occurs while updating Redis, the promise will be rejected with the error. + */ async addToRoom( roomName: string, connection: Connection | string @@ -39,6 +67,16 @@ export class RoomManager { await this.redis.sadd(this.connectionsRoomKey(connectionId), roomName); } + /** + * Removes a connection from a specified room and updates Redis accordingly. + * Accepts either a Connection object or a string representing the connection ID. + * Updates both the room's set of connections and the connection's set of rooms in Redis. + * + * @param {string} roomName - The name of the room from which to remove the connection. + * @param {Connection | string} connection - The connection to be removed, specified as either a Connection object or a connection ID string. + * @returns {Promise} A promise that resolves when the removal is complete. + * @throws {Error} If there is an error executing the Redis pipeline, the promise will be rejected with the error. + */ async removeFromRoom( roomName: string, connection: Connection | string @@ -51,6 +89,13 @@ export class RoomManager { await pipeline.exec(); } + /** + * Removes the specified connection from all rooms it is a member of and deletes its room membership record. + * + * @param {Connection | string} connection - The connection object or its unique identifier to be removed from all rooms. + * @returns {Promise} A promise that resolves once the removal from all rooms is complete. + * @throws {Error} If an error occurs during Redis operations, the promise will be rejected with the error. + */ async removeFromAllRooms(connection: Connection | string) { const connectionId = typeof connection === "string" ? connection : connection.id; @@ -65,6 +110,15 @@ export class RoomManager { await pipeline.exec(); } + /** + * Removes all associations and metadata for the specified room. This includes + * removing the room from all connected clients, deleting the room's key, and + * deleting any associated metadata in Redis. + * + * @param {string} roomName - The name of the room to be cleared. + * @returns {Promise} A promise that resolves when the room and its metadata have been cleared. + * @throws {Error} If an error occurs while interacting with Redis, the promise will be rejected with the error. + */ async clearRoom(roomName: string) { const connectionIds = await this.getRoomConnectionIds(roomName); const pipeline = this.redis.pipeline(); @@ -72,9 +126,18 @@ export class RoomManager { pipeline.srem(this.connectionsRoomKey(connectionId), roomName); } pipeline.del(this.roomKey(roomName)); + pipeline.del(this.roomMetadataKey(roomName)); await pipeline.exec(); } + /** + * Cleans up all Redis references for a given connection by removing the connection + * from all rooms it is associated with and deleting the connection's room key. + * + * @param {Connection} connection - The connection object whose references should be cleaned up. + * @returns {Promise} A promise that resolves when the cleanup is complete. + * @throws {Error} If an error occurs while interacting with Redis, the promise will be rejected with the error. + */ async cleanupConnection(connection: Connection): Promise { const rooms = await this.redis.smembers( this.connectionsRoomKey(connection.id) @@ -86,4 +149,86 @@ export class RoomManager { pipeline.del(this.connectionsRoomKey(connection.id)); await pipeline.exec(); } + + /** + * Sets the metadata for a given room by storing the serialized metadata + * object in Redis under the room's metadata key. + * + * @param {string} roomName - The unique name of the room whose metadata is being set. + * @param {any} metadata - The metadata object to associate with the room. This object will be stringified before storage. + * @returns {Promise} A promise that resolves when the metadata has been successfully set. + * @throws {Error} If an error occurs while storing metadata in Redis, the promise will be rejected with the error. + */ + async setMetadata(roomName: string, metadata: any): Promise { + await this.redis.hset( + this.roomMetadataKey(roomName), + "data", + JSON.stringify(metadata) + ); + } + + /** + * Retrieves and parses metadata associated with the specified room from Redis storage. + * + * @param {string} roomName - The name of the room whose metadata is to be retrieved. + * @returns {Promise} A promise that resolves to the parsed metadata object if found, + * or null if no metadata exists for the given room. + * @throws {SyntaxError} If the retrieved data is not valid JSON and cannot be parsed. + * @throws {Error} If there is an issue communicating with Redis. + */ + async getMetadata(roomName: string): Promise { + const data = await this.redis.hget(this.roomMetadataKey(roomName), "data"); + return data ? JSON.parse(data) : null; + } + + /** + * Updates the metadata for the specified room by merging the current metadata + * with the provided partial update object. The merged result is then saved as + * the new metadata for the room. + * + * @param {string} roomName - The name of the room whose metadata is to be updated. + * @param {any} partialUpdate - An object containing the fields to update within the room's metadata. + * @returns {Promise} A promise that resolves when the metadata update is complete. + * @throws {Error} If retrieving or setting metadata fails, the promise will be rejected with the error. + */ + async updateMetadata(roomName: string, partialUpdate: any): Promise { + const currentMetadata = (await this.getMetadata(roomName)) || {}; + const updatedMetadata = { ...currentMetadata, ...partialUpdate }; + await this.setMetadata(roomName, updatedMetadata); + } + + /** + * Retrieves and returns all room metadata stored in Redis. + * Fetches all keys matching the pattern "mesh:roommeta:*", retrieves their "data" fields, + * parses them as JSON, and returns an object mapping room names to their metadata. + * + * @returns {Promise<{ [roomName: string]: any }>} A promise that resolves to an object mapping room names to their metadata. + * @throws {SyntaxError} If the stored metadata cannot be parsed as JSON, an error is logged and the room is omitted from the result. + */ + async getAllMetadata(): Promise<{ [roomName: string]: any }> { + const keys = await this.redis.keys("mesh:roommeta:*"); + const metadata: { [roomName: string]: any } = {}; + + if (keys.length === 0) { + return metadata; + } + + const pipeline = this.redis.pipeline(); + keys.forEach((key) => pipeline.hget(key, "data")); + const results = await pipeline.exec(); + + keys.forEach((key, index) => { + const roomName = key.replace("mesh:roommeta:", ""); + const data = results?.[index]?.[1]; + if (data) { + try { + metadata[roomName] = JSON.parse(data as string); + } catch (e) { + console.error(`Failed to parse metadata for room ${roomName}:`, e); + } + } + }); + + return metadata; + } } diff --git a/packages/mesh/src/tests/rooms.test.ts b/packages/mesh/src/tests/rooms.test.ts index 8287cdd..028f829 100644 --- a/packages/mesh/src/tests/rooms.test.ts +++ b/packages/mesh/src/tests/rooms.test.ts @@ -69,4 +69,39 @@ describe("KeepAliveServer", () => { expect(await server.isInRoom("room2", connectionB)).toBe(false); expect(await server.isInRoom("room3", connectionA)).toBe(false); }); + + test("room metadata", async () => { + const room1 = "meta-room-1"; + const room2 = "meta-room-2"; + + const initialMeta1 = { topic: "General", owner: "userA" }; + await server.roomManager.setMetadata(room1, initialMeta1); + + let meta1 = await server.roomManager.getMetadata(room1); + expect(meta1).toEqual(initialMeta1); + + const updateMeta1 = { topic: "Updated Topic", settings: { max: 10 } }; + await server.roomManager.updateMetadata(room1, updateMeta1); + + meta1 = await server.roomManager.getMetadata(room1); + expect(meta1).toEqual({ ...initialMeta1, ...updateMeta1 }); + + const initialMeta2 = { topic: "Gaming", private: true }; + await server.roomManager.setMetadata(room2, initialMeta2); + + expect(await server.roomManager.getMetadata(room2)).toEqual(initialMeta2); + + expect( + await server.roomManager.getMetadata("non-existent-room") + ).toBeNull(); + + const allMeta = await server.roomManager.getAllMetadata(); + expect(allMeta).toEqual({ + [room1]: { ...initialMeta1, ...updateMeta1 }, + [room2]: initialMeta2, + }); + + await server.roomManager.clearRoom(room1); + expect(await server.roomManager.getMetadata(room1)).toBeNull(); + }); });