mirror of
https://github.com/nvms/prsm.git
synced 2025-12-15 15:50:53 +00:00
feature: presence management
This commit is contained in:
parent
6bd9803c61
commit
6181227532
@ -2,6 +2,7 @@
|
||||
|
||||
Mesh is a command-based WebSocket framework for real-time apps—whether you're running a single server or a distributed cluster. It uses Redis to coordinate connections, rooms, and shared state across instances, with built-in support for structured commands, latency tracking, and automatic reconnection.
|
||||
|
||||
|
||||
* [Quickstart](#quickstart)
|
||||
* [Server](#server)
|
||||
* [Client](#client)
|
||||
@ -12,10 +13,15 @@ Mesh is a command-based WebSocket framework for real-time apps—whether you're
|
||||
* [Server configuration](#server-configuration)
|
||||
* [Server publishing](#server-publishing)
|
||||
* [Client usage](#client-usage)
|
||||
* [Presence](#presence)
|
||||
* [Server configuration](#server-configuration-1)
|
||||
* [Getting presence information (server-side)](#getting-presence-information-server-side)
|
||||
* [Client usage](#client-usage-1)
|
||||
* [Presence and metadata together](#presence-and-metadata-together)
|
||||
* [Metadata](#metadata)
|
||||
* [Room metadata](#room-metadata)
|
||||
* [Record subscriptions](#record-subscriptions)
|
||||
* [Server configuration](#server-configuration-1)
|
||||
* [Server configuration](#server-configuration-2)
|
||||
* [Server configuration (writable)](#server-configuration-writable)
|
||||
* [Updating records (server-side)](#updating-records-server-side)
|
||||
* [Updating records (client-side)](#updating-records-client-side)
|
||||
@ -69,10 +75,13 @@ console.log(response); // "echo: Hello!"
|
||||
|
||||
Mesh supports multiple real-time patterns—choose where to go next based on your use case:
|
||||
|
||||
- **Pub/Sub messaging (e.g. chat, notifications):**
|
||||
- **Pub/Sub messaging (e.g. chat, notifications):**
|
||||
→ [Redis channel subscriptions](#redis-channel-subscriptions)
|
||||
|
||||
- **Granular, versioned data sync (e.g. user profiles, dashboards):**
|
||||
- **Real-time presence tracking (e.g. who's online, typing indicators):**
|
||||
→ [Presence](#presence)
|
||||
|
||||
- **Granular, versioned data sync (e.g. user profiles, dashboards):**
|
||||
→ [Record subscriptions](#record-subscriptions)
|
||||
|
||||
- **Identify users, store connection info, or manage rooms:**
|
||||
@ -220,6 +229,127 @@ This feature is great for:
|
||||
- Pub/sub messaging across distributed server instances
|
||||
- Notification feeds with instant context
|
||||
|
||||
## Presence
|
||||
|
||||
Mesh provides a built-in presence system that tracks which connections are present in specific rooms and notifies clients when connections join or leave. This is ideal for building features like "who's online" indicators, typing indicators, or any real-time awareness of other users.
|
||||
|
||||
> [!NOTE]
|
||||
> Presence only tracks *connection IDs*, not metadata. You must join them explicitly if you want to show e.g. usernames, avatars, emails, etc.
|
||||
|
||||
### Server configuration
|
||||
|
||||
Enable presence tracking for specific rooms using exact names or regex patterns:
|
||||
|
||||
```ts
|
||||
// track presence for all rooms matching a pattern
|
||||
server.trackPresence(/^room:.*$/);
|
||||
|
||||
// track presence for a specific room
|
||||
server.trackPresence("lobby");
|
||||
|
||||
// guard who can see presence
|
||||
server.trackPresence("admin-room", async (conn, roomName) => {
|
||||
const meta = await server.connectionManager.getMetadata(conn);
|
||||
return meta?.isAdmin === true;
|
||||
});
|
||||
|
||||
// custom TTL
|
||||
server.trackPresence("game-room", { ttl: 60_000 }); // ms
|
||||
|
||||
// guard and TTL
|
||||
server.trackPresence("vip-room", {
|
||||
ttl: 30_000,
|
||||
guard: async (conn, roomName) => {
|
||||
const meta = await server.connectionManager.getMetadata(conn);
|
||||
return meta?.isVIP === true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
When presence is enabled for a room, Mesh automatically:
|
||||
|
||||
1. Tracks which connections are present in the room
|
||||
2. Emits presence events when connections join or leave
|
||||
3. Refreshes presence TTL when connections send pong responses
|
||||
4. Cleans up presence when connections disconnect
|
||||
|
||||
### Getting presence information (server-side)
|
||||
|
||||
```ts
|
||||
// Get all connections currently present in a room
|
||||
const connectionIds = await server.presenceManager.getPresentConnections("lobby");
|
||||
```
|
||||
|
||||
### Client usage
|
||||
|
||||
Subscribe to presence updates for 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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// initial list of present connections
|
||||
console.log("Currently present:", present); // ["conn1", "conn2", ...]
|
||||
```
|
||||
|
||||
Unsubscribe when no longer needed:
|
||||
|
||||
```ts
|
||||
await client.unsubscribePresence("lobby");
|
||||
```
|
||||
|
||||
### Presence and metadata together
|
||||
|
||||
Presence is most useful when combined with connection metadata. For example:
|
||||
|
||||
```ts
|
||||
// server: set user metadata when they connect
|
||||
server.onConnection(async (connection) => {
|
||||
// maybe from an auth token or session
|
||||
await server.connectionManager.setMetadata(connection, {
|
||||
userId: "user123",
|
||||
username: "Alice",
|
||||
avatar: "https://example.com/avatar.png"
|
||||
});
|
||||
});
|
||||
|
||||
// client: subscribe to presence and resolve metadata
|
||||
const { success, present } = await client.subscribePresence(
|
||||
"lobby",
|
||||
async (update) => {
|
||||
// fetch metadata for the connection that joined/left.
|
||||
//
|
||||
// since clients cannot access `getAllMetadataForRoom()` directly (it's just a server API),
|
||||
// you can expose it via a custom command like `get-user-metadata`:
|
||||
const metadata = await client.command("get-user-metadata", {
|
||||
connectionId: update.connectionId
|
||||
});
|
||||
|
||||
if (update.type === "join") {
|
||||
console.log(`${metadata.username} joined the lobby`);
|
||||
} else if (update.type === "leave") {
|
||||
console.log(`${metadata.username} left the lobby`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// initial presence - fetch metadata for all present connections
|
||||
const allMetadata = await Promise.all(
|
||||
present.map(connectionId =>
|
||||
client.command("get-user-metadata", { connectionId })
|
||||
)
|
||||
);
|
||||
console.log("Users in lobby:", allMetadata);
|
||||
```
|
||||
|
||||
### Metadata
|
||||
|
||||
You can associate data like user IDs, tokens, or custom attributes with a connection using the `setMetadata` method. This metadata is stored in Redis, making it ideal for identifying users, managing permissions, or persisting session-related data across a distributed setup. Since it lives in Redis, it’s accessible from any server instance.
|
||||
@ -587,6 +717,7 @@ Together, this system provides end-to-end connection liveness guarantees without
|
||||
| **Command API (RPC)** | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
| **Raw Events Support** | ✅ | ✅ | ⚠️ Limited | ✅ | ✅ | ✅ |
|
||||
| **Room Support** | ✅ | ✅ | ✅ | ✅ | ⚠️ DIY | ⚠️ Manual |
|
||||
| **Presence Tracking** | ✅ Built-in | ⚠️ Manual | ✅ | ✅ | ❌ | ❌ |
|
||||
| **Redis Scaling** | ✅ Native | ✅ With adapter | ✅ | ✅ | ✅ If added | ❌ |
|
||||
| **Connection Metadata** | ✅ Redis-backed | ⚠️ Manual | ⚠️ Limited | ✅ Records | ❌ | ❌ |
|
||||
| **Latency Tracking** | ✅ Built-in | ⚠️ Manual | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
@ -76,6 +76,17 @@ export class MeshClient extends EventEmitter {
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
private presenceSubscriptions: Map<
|
||||
string, // roomName
|
||||
(update: {
|
||||
type: "join" | "leave";
|
||||
connectionId: string;
|
||||
roomName: string;
|
||||
timestamp: number;
|
||||
metadata?: any;
|
||||
}) => void | Promise<void>
|
||||
> = new Map();
|
||||
|
||||
constructor(url: string, opts: MeshClientOptions = {}) {
|
||||
super();
|
||||
this.url = url;
|
||||
@ -101,6 +112,8 @@ export class MeshClient extends EventEmitter {
|
||||
|
||||
if (data.command === "record-update") {
|
||||
this.handleRecordUpdate(data.payload);
|
||||
} else if (data.command === "presence-update") {
|
||||
this.handlePresenceUpdate(data.payload);
|
||||
} else if (data.command === "subscription-message") {
|
||||
this.emit(data.command, data.payload);
|
||||
} else {
|
||||
@ -372,6 +385,21 @@ export class MeshClient extends EventEmitter {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async handlePresenceUpdate(payload: {
|
||||
type: "join" | "leave";
|
||||
connectionId: string;
|
||||
roomName: string;
|
||||
timestamp: number;
|
||||
metadata?: any;
|
||||
}) {
|
||||
const { roomName } = payload;
|
||||
const callback = this.presenceSubscriptions.get(roomName);
|
||||
|
||||
if (callback) {
|
||||
await callback(payload);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRecordUpdate(payload: {
|
||||
recordId: string;
|
||||
full?: any;
|
||||
@ -561,4 +589,63 @@ export class MeshClient extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to presence updates for a specific room.
|
||||
*
|
||||
* @param {string} roomName - The name of the room to subscribe to presence updates for.
|
||||
* @param {(update: { type: "join" | "leave"; connectionId: string; roomName: string; timestamp: number; metadata?: any }) => void | Promise<void>} callback - Function called on presence updates.
|
||||
* @returns {Promise<{ success: boolean; present: string[] }>} Initial state of presence in the room.
|
||||
*/
|
||||
async subscribePresence(
|
||||
roomName: string,
|
||||
callback: (update: {
|
||||
type: "join" | "leave";
|
||||
connectionId: string;
|
||||
roomName: string;
|
||||
timestamp: number;
|
||||
metadata?: any;
|
||||
}) => void | Promise<void>
|
||||
): Promise<{ success: boolean; present: string[] }> {
|
||||
try {
|
||||
const result = await this.command("subscribe-presence", { roomName });
|
||||
|
||||
if (result.success) {
|
||||
this.presenceSubscriptions.set(roomName, callback);
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
present: result.present || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[MeshClient] Failed to subscribe to presence for room ${roomName}:`,
|
||||
error
|
||||
);
|
||||
return { success: false, present: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from presence updates for a specific room.
|
||||
*
|
||||
* @param {string} roomName - The name of the room to unsubscribe from.
|
||||
* @returns {Promise<boolean>} True if successful, false otherwise.
|
||||
*/
|
||||
async unsubscribePresence(roomName: string): Promise<boolean> {
|
||||
try {
|
||||
const success = await this.command("unsubscribe-presence", { roomName });
|
||||
if (success) {
|
||||
this.presenceSubscriptions.delete(roomName);
|
||||
}
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[MeshClient] Failed to unsubscribe from presence for room ${roomName}:`,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,6 +100,10 @@ export class Connection extends EventEmitter {
|
||||
} else if (command.command === "pong") {
|
||||
this.alive = true;
|
||||
this.missedPongs = 0;
|
||||
|
||||
// this refreshes presence TTL for all rooms this connection is in
|
||||
this.emit("pong", this.id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { WebSocket, WebSocketServer, type ServerOptions } from "ws";
|
||||
import { RoomManager } from "./room-manager";
|
||||
import { RecordManager } from "./record-manager";
|
||||
import { ConnectionManager } from "./connection-manager";
|
||||
import { PresenceManager } from "./presence-manager";
|
||||
import { CodeError, Status } from "../client";
|
||||
import { Connection } from "./connection";
|
||||
import { parseCommand, type Command } from "../common/message";
|
||||
@ -89,6 +90,7 @@ export class MeshServer extends WebSocketServer {
|
||||
roomManager: RoomManager;
|
||||
recordManager: RecordManager;
|
||||
connectionManager: ConnectionManager;
|
||||
presenceManager: PresenceManager;
|
||||
serverOptions: MeshServerOptions;
|
||||
status: Status = Status.OFFLINE;
|
||||
private exposedChannels: ChannelPattern[] = [];
|
||||
@ -170,6 +172,7 @@ export class MeshServer extends WebSocketServer {
|
||||
this.instanceId,
|
||||
this.roomManager
|
||||
);
|
||||
this.presenceManager = new PresenceManager(this.redis, this.roomManager);
|
||||
|
||||
this.subscribeToInstanceChannel();
|
||||
|
||||
@ -228,6 +231,18 @@ export class MeshServer extends WebSocketServer {
|
||||
this.handleInstancePubSubMessage(channel, message);
|
||||
} else if (channel === RECORD_PUB_SUB_CHANNEL) {
|
||||
this.handleRecordUpdatePubSubMessage(message);
|
||||
} else if (channel.startsWith("mesh:presence:updates:")) {
|
||||
const roomName = channel.replace("mesh:presence:updates:", "");
|
||||
if (this.channelSubscriptions[channel]) {
|
||||
for (const connection of this.channelSubscriptions[channel]) {
|
||||
if (!connection.isDead) {
|
||||
connection.send({
|
||||
command: "presence-update",
|
||||
payload: JSON.parse(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.channelSubscriptions[channel]) {
|
||||
for (const connection of this.channelSubscriptions[channel]) {
|
||||
if (!connection.isDead) {
|
||||
@ -358,6 +373,24 @@ export class MeshServer extends WebSocketServer {
|
||||
connection.on("error", (err) => {
|
||||
this.emit("clientError", err, connection);
|
||||
});
|
||||
|
||||
connection.on("pong", async (connectionId) => {
|
||||
try {
|
||||
const rooms = await this.roomManager.getRoomsForConnection(
|
||||
connectionId
|
||||
);
|
||||
for (const roomName of rooms) {
|
||||
if (await this.presenceManager.isRoomTracked(roomName)) {
|
||||
await this.presenceManager.refreshPresence(
|
||||
connectionId,
|
||||
roomName
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.emit("error", new Error(`Failed to refresh presence: ${err}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -614,6 +647,24 @@ export class MeshServer extends WebSocketServer {
|
||||
}
|
||||
}
|
||||
|
||||
trackPresence(
|
||||
roomPattern: string | RegExp,
|
||||
guardOrOptions?:
|
||||
| ((
|
||||
connection: Connection,
|
||||
roomName: string
|
||||
) => Promise<boolean> | boolean)
|
||||
| {
|
||||
ttl?: number;
|
||||
guard?: (
|
||||
connection: Connection,
|
||||
roomName: string
|
||||
) => Promise<boolean> | boolean;
|
||||
}
|
||||
): void {
|
||||
this.presenceManager.trackRoom(roomPattern, guardOrOptions);
|
||||
}
|
||||
|
||||
private registerBuiltinCommands() {
|
||||
this.registerCommand<
|
||||
{ channel: string; historyLimit?: number },
|
||||
@ -746,6 +797,74 @@ export class MeshServer extends WebSocketServer {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.registerCommand<
|
||||
{ roomName: string },
|
||||
{ success: boolean; present: string[] }
|
||||
>("subscribe-presence", async (ctx) => {
|
||||
const { roomName } = ctx.payload;
|
||||
const connectionId = ctx.connection.id;
|
||||
|
||||
if (
|
||||
!(await this.presenceManager.isRoomTracked(roomName, ctx.connection))
|
||||
) {
|
||||
return { success: false, present: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const presenceChannel = `mesh:presence:updates:${roomName}`;
|
||||
|
||||
if (!this.channelSubscriptions[presenceChannel]) {
|
||||
this.channelSubscriptions[presenceChannel] = new Set();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.subClient.subscribe(presenceChannel, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.channelSubscriptions[presenceChannel].add(ctx.connection);
|
||||
|
||||
const present = await this.presenceManager.getPresentConnections(
|
||||
roomName
|
||||
);
|
||||
|
||||
return { success: true, present };
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to subscribe to presence for room ${roomName}:`,
|
||||
e
|
||||
);
|
||||
return { success: false, present: [] };
|
||||
}
|
||||
});
|
||||
|
||||
this.registerCommand<{ roomName: string }, boolean>(
|
||||
"unsubscribe-presence",
|
||||
async (ctx) => {
|
||||
const { roomName } = ctx.payload;
|
||||
const presenceChannel = `mesh:presence:updates:${roomName}`;
|
||||
|
||||
if (this.channelSubscriptions[presenceChannel]) {
|
||||
this.channelSubscriptions[presenceChannel].delete(ctx.connection);
|
||||
|
||||
if (this.channelSubscriptions[presenceChannel].size === 0) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.subClient.unsubscribe(presenceChannel, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
delete this.channelSubscriptions[presenceChannel];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1016,10 +1135,23 @@ export class MeshServer extends WebSocketServer {
|
||||
}
|
||||
|
||||
async addToRoom(roomName: string, connection: Connection | string) {
|
||||
return this.roomManager.addToRoom(roomName, connection);
|
||||
const connectionId =
|
||||
typeof connection === "string" ? connection : connection.id;
|
||||
await this.roomManager.addToRoom(roomName, connection);
|
||||
|
||||
if (await this.presenceManager.isRoomTracked(roomName)) {
|
||||
await this.presenceManager.markOnline(connectionId, roomName);
|
||||
}
|
||||
}
|
||||
|
||||
async removeFromRoom(roomName: string, connection: Connection | string) {
|
||||
const connectionId =
|
||||
typeof connection === "string" ? connection : connection.id;
|
||||
|
||||
if (await this.presenceManager.isRoomTracked(roomName)) {
|
||||
await this.presenceManager.markOffline(connectionId, roomName);
|
||||
}
|
||||
|
||||
return this.roomManager.removeFromRoom(roomName, connection);
|
||||
}
|
||||
|
||||
@ -1042,9 +1174,14 @@ export class MeshServer extends WebSocketServer {
|
||||
private async cleanupConnection(connection: Connection) {
|
||||
connection.stopIntervals();
|
||||
|
||||
await this.connectionManager.cleanupConnection(connection);
|
||||
await this.roomManager.cleanupConnection(connection);
|
||||
await this.cleanupRecordSubscriptions(connection); // Ensure this line exists
|
||||
try {
|
||||
await this.presenceManager.cleanupConnection(connection);
|
||||
await this.connectionManager.cleanupConnection(connection);
|
||||
await this.roomManager.cleanupConnection(connection);
|
||||
await this.cleanupRecordSubscriptions(connection);
|
||||
} catch (err) {
|
||||
this.emit("error", new Error(`Failed to clean up connection: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1102,7 +1239,7 @@ export class MeshServer extends WebSocketServer {
|
||||
this.on("connected", callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Registers a callback function to be executed when a connection is closed.
|
||||
*
|
||||
|
||||
171
packages/mesh/src/server/presence-manager.ts
Normal file
171
packages/mesh/src/server/presence-manager.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import type { Redis } from "ioredis";
|
||||
import type { Connection } from "./connection";
|
||||
import type { RoomManager } from "./room-manager";
|
||||
|
||||
type ChannelPattern = string | RegExp;
|
||||
|
||||
export class PresenceManager {
|
||||
private redis: Redis;
|
||||
private roomManager: RoomManager;
|
||||
private trackedRooms: ChannelPattern[] = [];
|
||||
private roomGuards: Map<
|
||||
ChannelPattern,
|
||||
(connection: Connection, roomName: string) => Promise<boolean> | boolean
|
||||
> = new Map();
|
||||
private roomTTLs: Map<ChannelPattern, number> = new Map();
|
||||
private defaultTTL = 30_000; // 30 seconds default TTL
|
||||
|
||||
constructor(redis: Redis, roomManager: RoomManager) {
|
||||
this.redis = redis;
|
||||
this.roomManager = roomManager;
|
||||
}
|
||||
|
||||
trackRoom(
|
||||
roomPattern: ChannelPattern,
|
||||
guardOrOptions?:
|
||||
| ((
|
||||
connection: Connection,
|
||||
roomName: string
|
||||
) => Promise<boolean> | boolean)
|
||||
| {
|
||||
ttl?: number;
|
||||
guard?: (
|
||||
connection: Connection,
|
||||
roomName: string
|
||||
) => Promise<boolean> | boolean;
|
||||
}
|
||||
): void {
|
||||
this.trackedRooms.push(roomPattern);
|
||||
|
||||
if (typeof guardOrOptions === "function") {
|
||||
this.roomGuards.set(roomPattern, guardOrOptions);
|
||||
} else if (guardOrOptions && typeof guardOrOptions === "object") {
|
||||
if (guardOrOptions.guard) {
|
||||
this.roomGuards.set(roomPattern, guardOrOptions.guard);
|
||||
}
|
||||
|
||||
if (guardOrOptions.ttl && typeof guardOrOptions.ttl === "number") {
|
||||
this.roomTTLs.set(roomPattern, guardOrOptions.ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async isRoomTracked(
|
||||
roomName: string,
|
||||
connection?: Connection
|
||||
): Promise<boolean> {
|
||||
const matchedPattern = this.trackedRooms.find((pattern) =>
|
||||
typeof pattern === "string"
|
||||
? pattern === roomName
|
||||
: pattern.test(roomName)
|
||||
);
|
||||
|
||||
if (!matchedPattern) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (connection) {
|
||||
const guard = this.roomGuards.get(matchedPattern);
|
||||
if (guard) {
|
||||
try {
|
||||
return await Promise.resolve(guard(connection, roomName));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getRoomTTL(roomName: string): number {
|
||||
const matchedPattern = this.trackedRooms.find((pattern) =>
|
||||
typeof pattern === "string"
|
||||
? pattern === roomName
|
||||
: pattern.test(roomName)
|
||||
);
|
||||
|
||||
if (matchedPattern) {
|
||||
const ttl = this.roomTTLs.get(matchedPattern);
|
||||
if (ttl !== undefined) {
|
||||
return ttl;
|
||||
}
|
||||
}
|
||||
|
||||
return this.defaultTTL;
|
||||
}
|
||||
|
||||
private presenceRoomKey(roomName: string): string {
|
||||
return `mesh:presence:room:${roomName}`;
|
||||
}
|
||||
|
||||
private presenceConnectionKey(
|
||||
roomName: string,
|
||||
connectionId: string
|
||||
): string {
|
||||
return `mesh:presence:room:${roomName}:conn:${connectionId}`;
|
||||
}
|
||||
|
||||
async markOnline(connectionId: string, roomName: string): Promise<void> {
|
||||
const roomKey = this.presenceRoomKey(roomName);
|
||||
const connKey = this.presenceConnectionKey(roomName, connectionId);
|
||||
const ttl = this.getRoomTTL(roomName);
|
||||
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.sadd(roomKey, connectionId);
|
||||
pipeline.set(connKey, "", "EX", Math.floor(ttl / 1000));
|
||||
await pipeline.exec();
|
||||
|
||||
await this.publishPresenceUpdate(roomName, connectionId, "join");
|
||||
}
|
||||
|
||||
async markOffline(connectionId: string, roomName: string): Promise<void> {
|
||||
const roomKey = this.presenceRoomKey(roomName);
|
||||
const connKey = this.presenceConnectionKey(roomName, connectionId);
|
||||
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.srem(roomKey, connectionId);
|
||||
pipeline.del(connKey);
|
||||
await pipeline.exec();
|
||||
|
||||
await this.publishPresenceUpdate(roomName, connectionId, "leave");
|
||||
}
|
||||
|
||||
async refreshPresence(connectionId: string, roomName: string): Promise<void> {
|
||||
const connKey = this.presenceConnectionKey(roomName, connectionId);
|
||||
const ttl = this.getRoomTTL(roomName);
|
||||
|
||||
await this.redis.set(connKey, "", "EX", Math.floor(ttl / 1000));
|
||||
}
|
||||
|
||||
async getPresentConnections(roomName: string): Promise<string[]> {
|
||||
return this.redis.smembers(this.presenceRoomKey(roomName));
|
||||
}
|
||||
|
||||
private async publishPresenceUpdate(
|
||||
roomName: string,
|
||||
connectionId: string,
|
||||
type: "join" | "leave"
|
||||
): Promise<void> {
|
||||
const channel = `mesh:presence:updates:${roomName}`;
|
||||
const message = JSON.stringify({
|
||||
type,
|
||||
connectionId,
|
||||
roomName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await this.redis.publish(channel, message);
|
||||
}
|
||||
|
||||
async cleanupConnection(connection: Connection): Promise<void> {
|
||||
const connectionId = connection.id;
|
||||
const rooms = await this.roomManager.getRoomsForConnection(connectionId);
|
||||
|
||||
for (const roomName of rooms) {
|
||||
if (await this.isRoomTracked(roomName)) {
|
||||
await this.markOffline(connectionId, roomName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,11 +9,11 @@ export class RoomManager {
|
||||
}
|
||||
|
||||
private roomKey(roomName: string) {
|
||||
return `room:${roomName}`;
|
||||
return `mesh:room:${roomName}`;
|
||||
}
|
||||
|
||||
private connectionsRoomKey(connectionId: string) {
|
||||
return `connection:${connectionId}:rooms`;
|
||||
return `mesh:connection:${connectionId}:rooms`;
|
||||
}
|
||||
|
||||
private roomMetadataKey(roomName: string) {
|
||||
|
||||
246
packages/mesh/src/tests/presence-subscription.test.ts
Normal file
246
packages/mesh/src/tests/presence-subscription.test.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import Redis from "ioredis";
|
||||
import { MeshServer } from "../server";
|
||||
import { MeshClient } from "../client";
|
||||
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
|
||||
const REDIS_PORT = process.env.REDIS_PORT
|
||||
? parseInt(process.env.REDIS_PORT, 10)
|
||||
: 6379;
|
||||
|
||||
const createTestServer = (port: number) =>
|
||||
new MeshServer({
|
||||
port,
|
||||
redisOptions: {
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
},
|
||||
pingInterval: 1000,
|
||||
latencyInterval: 500,
|
||||
});
|
||||
|
||||
const flushRedis = async () => {
|
||||
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
|
||||
await redis.flushdb();
|
||||
await redis.quit();
|
||||
};
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe("Presence Subscription", () => {
|
||||
const port = 8140;
|
||||
let server: MeshServer;
|
||||
let client1: MeshClient;
|
||||
let client2: MeshClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await flushRedis();
|
||||
|
||||
server = createTestServer(port);
|
||||
server.trackPresence(/^test:room:.*/);
|
||||
server.trackPresence("guarded:room");
|
||||
await server.ready();
|
||||
|
||||
client1 = new MeshClient(`ws://localhost:${port}`);
|
||||
client2 = new MeshClient(`ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client1.close();
|
||||
await client2.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("client can subscribe to presence for a tracked room", async () => {
|
||||
const roomName = "test:room:1";
|
||||
await client1.connect();
|
||||
|
||||
const callback = vi.fn();
|
||||
const { success, present } = await client1.subscribePresence(
|
||||
roomName,
|
||||
callback
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(Array.isArray(present)).toBe(true);
|
||||
expect(present.length).toBe(0);
|
||||
});
|
||||
|
||||
test("client cannot subscribe to presence for an untracked room", async () => {
|
||||
const roomName = "untracked:room";
|
||||
await client1.connect();
|
||||
|
||||
const callback = vi.fn();
|
||||
const { success, present } = await client1.subscribePresence(
|
||||
roomName,
|
||||
callback
|
||||
);
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(present.length).toBe(0);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("presence guard prevents unauthorized subscriptions", async () => {
|
||||
await client1.connect();
|
||||
await client2.connect();
|
||||
|
||||
const connections = server.connectionManager.getLocalConnections();
|
||||
const connection1Id = connections[0]?.id;
|
||||
|
||||
server.trackPresence(
|
||||
"guarded:room",
|
||||
(connection, roomName) => connection.id === connection1Id
|
||||
);
|
||||
|
||||
const callback1 = vi.fn();
|
||||
const result1 = await client1.subscribePresence("guarded:room", callback1);
|
||||
|
||||
const callback2 = vi.fn();
|
||||
const result2 = await client2.subscribePresence("guarded:room", callback2);
|
||||
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result2.success).toBe(false);
|
||||
expect(callback2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("client receives presence updates when users join and leave", async () => {
|
||||
const roomName = "test:room:updates";
|
||||
await client1.connect();
|
||||
await client2.connect();
|
||||
|
||||
const updates: any[] = [];
|
||||
const callback = vi.fn((update: any) => {
|
||||
updates.push(update);
|
||||
});
|
||||
|
||||
await client1.subscribePresence(roomName, callback);
|
||||
|
||||
const connections = server.connectionManager.getLocalConnections();
|
||||
|
||||
await server.addToRoom(roomName, connections[1]!);
|
||||
await wait(100);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(updates[0].type).toBe("join");
|
||||
expect(updates[0].roomName).toBe(roomName);
|
||||
expect(typeof updates[0].connectionId).toBe("string");
|
||||
expect(typeof updates[0].timestamp).toBe("number");
|
||||
|
||||
await server.removeFromRoom(roomName, connections[1]!);
|
||||
await wait(100);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(updates[1].type).toBe("leave");
|
||||
expect(updates[1].roomName).toBe(roomName);
|
||||
expect(typeof updates[1].connectionId).toBe("string");
|
||||
expect(typeof updates[1].timestamp).toBe("number");
|
||||
});
|
||||
|
||||
test("client stops receiving presence updates after unsubscribing", async () => {
|
||||
const roomName = "test:room:unsub";
|
||||
await client1.connect();
|
||||
await client2.connect();
|
||||
|
||||
const updates: any[] = [];
|
||||
const callback = vi.fn((update: any) => {
|
||||
updates.push(update);
|
||||
});
|
||||
|
||||
await client1.subscribePresence(roomName, callback);
|
||||
|
||||
const connections = server.connectionManager.getLocalConnections();
|
||||
|
||||
await server.addToRoom(roomName, connections[1]!);
|
||||
await wait(100);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
const unsubSuccess = await client1.unsubscribePresence(roomName);
|
||||
expect(unsubSuccess).toBe(true);
|
||||
|
||||
callback.mockReset();
|
||||
|
||||
await server.removeFromRoom(roomName, connections[1]!);
|
||||
await wait(100);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("presence is maintained with custom TTL", async () => {
|
||||
const roomName = "test:room:ttl";
|
||||
const shortTTL = 200;
|
||||
|
||||
server.trackPresence(roomName, { ttl: shortTTL });
|
||||
|
||||
await client1.connect();
|
||||
await client2.connect();
|
||||
|
||||
const connections = server.connectionManager.getLocalConnections();
|
||||
const connection2 = connections[1]!;
|
||||
|
||||
await server.addToRoom(roomName, connection2);
|
||||
|
||||
let present = await server.presenceManager.getPresentConnections(roomName);
|
||||
expect(present).toContain(connection2.id);
|
||||
|
||||
// wait for less than TTL and verify still present
|
||||
await wait(shortTTL / 2);
|
||||
present = await server.presenceManager.getPresentConnections(roomName);
|
||||
expect(present).toContain(connection2.id);
|
||||
|
||||
// simulate pong to refresh presence
|
||||
connection2.emit("pong", connection2.id);
|
||||
|
||||
// wait for more than the original TTL
|
||||
await wait(shortTTL + 100);
|
||||
|
||||
// should still be present because of the refresh
|
||||
present = await server.presenceManager.getPresentConnections(roomName);
|
||||
expect(present).toContain(connection2.id);
|
||||
});
|
||||
|
||||
test("initial presence list is correct when subscribing", async () => {
|
||||
const roomName = "test:room:initial";
|
||||
await client1.connect();
|
||||
await client2.connect();
|
||||
|
||||
const connections = server.connectionManager.getLocalConnections();
|
||||
|
||||
await server.addToRoom(roomName, connections[0]!);
|
||||
await server.addToRoom(roomName, connections[1]!);
|
||||
await wait(100);
|
||||
|
||||
const callback = vi.fn();
|
||||
const { success, present } = await client1.subscribePresence(
|
||||
roomName,
|
||||
callback
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(present.length).toBe(2);
|
||||
expect(present).toContain(connections[0]!.id);
|
||||
expect(present).toContain(connections[1]!.id);
|
||||
});
|
||||
|
||||
test("presence is cleaned up when connection is closed", async () => {
|
||||
const roomName = "test:room:cleanup";
|
||||
await client1.connect();
|
||||
await client2.connect();
|
||||
|
||||
const connections = server.connectionManager.getLocalConnections();
|
||||
const connection2 = connections[1]!;
|
||||
|
||||
await server.addToRoom(roomName, connection2);
|
||||
|
||||
let present = await server.presenceManager.getPresentConnections(roomName);
|
||||
expect(present).toContain(connection2.id);
|
||||
|
||||
await client2.close();
|
||||
|
||||
await wait(100);
|
||||
|
||||
present = await server.presenceManager.getPresentConnections(roomName);
|
||||
expect(present).not.toContain(connection2.id);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user