feature: presence management

This commit is contained in:
nvms 2025-04-18 17:17:12 -04:00
parent 6bd9803c61
commit 6181227532
7 changed files with 786 additions and 10 deletions

View File

@ -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, its 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 | ❌ | ❌ | ❌ | ❌ |

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -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.
*

View 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);
}
}
}
}

View File

@ -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) {

View 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);
});
});