mirror of
https://github.com/nvms/prsm.git
synced 2025-12-16 16:10:54 +00:00
redis-backed room support
This commit is contained in:
parent
5c322d6bbc
commit
7170d1bf89
@ -26,6 +26,9 @@ const server = new KeepAliveServer({
|
|||||||
port: 8080,
|
port: 8080,
|
||||||
pingInterval: 30000,
|
pingInterval: 30000,
|
||||||
latencyInterval: 5000,
|
latencyInterval: 5000,
|
||||||
|
// Multi-instance room support (optional):
|
||||||
|
// roomBackend: "redis",
|
||||||
|
// redisOptions: { host: "localhost", port: 6379 }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register command handlers
|
// Register command handlers
|
||||||
@ -41,8 +44,8 @@ server.registerCommand("throws", async () => {
|
|||||||
// Room-based messaging
|
// Room-based messaging
|
||||||
server.registerCommand("join-room", async (context) => {
|
server.registerCommand("join-room", async (context) => {
|
||||||
const { roomName } = context.payload;
|
const { roomName } = context.payload;
|
||||||
server.addToRoom(roomName, context.connection);
|
await server.addToRoom(roomName, context.connection);
|
||||||
server.broadcastRoom(roomName, "user-joined", {
|
await server.broadcastRoom(roomName, "user-joined", {
|
||||||
id: context.connection.id
|
id: context.connection.id
|
||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@ -101,17 +104,17 @@ await client.close();
|
|||||||
|
|
||||||
### Room Management
|
### Room Management
|
||||||
```typescript
|
```typescript
|
||||||
// Add a connection to a room
|
// Add a connection to a room (async)
|
||||||
server.addToRoom("roomName", connection);
|
await server.addToRoom("roomName", connection);
|
||||||
|
|
||||||
// Remove a connection from a room
|
// Remove a connection from a room (async)
|
||||||
server.removeFromRoom("roomName", connection);
|
await server.removeFromRoom("roomName", connection);
|
||||||
|
|
||||||
// Get all connections in a room
|
// Get all connections in a room (async)
|
||||||
const roomConnections = server.getRoom("roomName");
|
const roomConnections = await server.getRoom("roomName");
|
||||||
|
|
||||||
// Clear all connections from a room
|
// Clear all connections from a room (async)
|
||||||
server.clearRoom("roomName");
|
await server.clearRoom("roomName");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Broadcasting
|
### Broadcasting
|
||||||
@ -162,6 +165,22 @@ server.registerCommand(
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multi-Instance Room Support
|
||||||
|
|
||||||
|
To enable multi-instance room support (so rooms are shared across all server instances), configure the server with `roomBackend: "redis"` and provide `redisOptions`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { KeepAliveServer } from "@prsm/keepalive-ws/server";
|
||||||
|
|
||||||
|
const server = new KeepAliveServer({
|
||||||
|
port: 8080,
|
||||||
|
roomBackend: "redis",
|
||||||
|
redisOptions: { host: "localhost", port: 6379 }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
All room management methods become async and must be awaited.
|
||||||
|
|
||||||
## Graceful Shutdown
|
## Graceful Shutdown
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
Binary file not shown.
16
packages/keepalive-ws/docker-compose.yml
Normal file
16
packages/keepalive-ws/docker-compose.yml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||||
|
|
||||||
|
redis-commander:
|
||||||
|
image: rediscommander/redis-commander:latest
|
||||||
|
environment:
|
||||||
|
- REDIS_HOSTS=local:redis:6379
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
@ -40,6 +40,7 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
"ws": "^8.9.0"
|
"ws": "^8.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -73,7 +73,21 @@ export class KeepAliveClient extends EventEmitter {
|
|||||||
private setupConnectionEvents(): void {
|
private setupConnectionEvents(): void {
|
||||||
// Forward relevant events from connection to client
|
// Forward relevant events from connection to client
|
||||||
this.connection.on("message", (data) => {
|
this.connection.on("message", (data) => {
|
||||||
|
// Forward the raw message event
|
||||||
this.emit("message", data);
|
this.emit("message", data);
|
||||||
|
|
||||||
|
// Also forward the specific command event if it's not a system event
|
||||||
|
// (System events like ping/latency are handled separately below)
|
||||||
|
const systemCommands = [
|
||||||
|
"ping",
|
||||||
|
"pong",
|
||||||
|
"latency",
|
||||||
|
"latency:request",
|
||||||
|
"latency:response",
|
||||||
|
];
|
||||||
|
if (data.command && !systemCommands.includes(data.command)) {
|
||||||
|
this.emit(data.command, data.payload);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connection.on("close", () => {
|
this.connection.on("close", () => {
|
||||||
@ -150,8 +164,8 @@ export class KeepAliveClient extends EventEmitter {
|
|||||||
new CodeError(
|
new CodeError(
|
||||||
"WebSocket connection error",
|
"WebSocket connection error",
|
||||||
"ECONNECTION",
|
"ECONNECTION",
|
||||||
"ConnectionError",
|
"ConnectionError"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -233,11 +247,12 @@ export class KeepAliveClient extends EventEmitter {
|
|||||||
|
|
||||||
if (attempt <= this.options.maxReconnectAttempts) {
|
if (attempt <= this.options.maxReconnectAttempts) {
|
||||||
setTimeout(connect, this.options.reconnectInterval);
|
setTimeout(connect, this.options.reconnectInterval);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.isReconnecting = false;
|
this.isReconnecting = false;
|
||||||
this._status = Status.OFFLINE;
|
this._status = Status.OFFLINE;
|
||||||
this.emit("reconnectfailed");
|
this.emit("reconnectfailed");
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.socket.onopen = () => {
|
this.socket.onopen = () => {
|
||||||
@ -268,13 +283,13 @@ export class KeepAliveClient extends EventEmitter {
|
|||||||
command: string,
|
command: string,
|
||||||
payload?: any,
|
payload?: any,
|
||||||
expiresIn: number = 30000,
|
expiresIn: number = 30000,
|
||||||
callback?: (result: any, error?: Error) => void,
|
callback?: (result: any, error?: Error) => void
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Ensure we're connected before sending commands
|
// Ensure we're connected before sending commands
|
||||||
if (this._status !== Status.ONLINE) {
|
if (this._status !== Status.ONLINE) {
|
||||||
return this.connect()
|
return this.connect()
|
||||||
.then(() =>
|
.then(() =>
|
||||||
this.connection.command(command, payload, expiresIn, callback),
|
this.connection.command(command, payload, expiresIn, callback)
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
|
|||||||
@ -115,7 +115,7 @@ export class Connection extends EventEmitter {
|
|||||||
command: string,
|
command: string,
|
||||||
payload: any,
|
payload: any,
|
||||||
expiresIn: number | null = 30_000,
|
expiresIn: number | null = 30_000,
|
||||||
callback?: (result: any, error?: Error) => void,
|
callback?: (result: any, error?: Error) => void
|
||||||
): Promise<any> | null {
|
): Promise<any> | null {
|
||||||
const id = this.ids.reserve();
|
const id = this.ids.reserve();
|
||||||
const cmd: Command = { id, command, payload: payload ?? {} };
|
const cmd: Command = { id, command, payload: payload ?? {} };
|
||||||
@ -142,17 +142,17 @@ export class Connection extends EventEmitter {
|
|||||||
|
|
||||||
const timeoutPromise = new Promise<any>((_, reject) => {
|
const timeoutPromise = new Promise<any>((_, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.callbacks[id]) {
|
if (!this.callbacks[id]) return;
|
||||||
|
|
||||||
this.ids.release(id);
|
this.ids.release(id);
|
||||||
delete this.callbacks[id];
|
delete this.callbacks[id];
|
||||||
reject(
|
reject(
|
||||||
new CodeError(
|
new CodeError(
|
||||||
`Command timed out after ${expiresIn}ms.`,
|
`Command timed out after ${expiresIn}ms.`,
|
||||||
"ETIMEOUT",
|
"ETIMEOUT",
|
||||||
"TimeoutError",
|
"TimeoutError"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}, expiresIn);
|
}, expiresIn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export class IdManager {
|
|||||||
release(id: number) {
|
release(id: number) {
|
||||||
if (id < 0 || id > this.maxIndex) {
|
if (id < 0 || id > this.maxIndex) {
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`,
|
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.ids[id] = false;
|
this.ids[id] = false;
|
||||||
@ -36,7 +36,7 @@ export class IdManager {
|
|||||||
|
|
||||||
if (this.index === startIndex) {
|
if (this.index === startIndex) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`All IDs are reserved. Make sure to release IDs when they are no longer used.`,
|
`All IDs are reserved. Make sure to release IDs when they are no longer used.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export class Connection extends EventEmitter {
|
|||||||
constructor(
|
constructor(
|
||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
options: KeepAliveServerOptions,
|
options: KeepAliveServerOptions
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { IncomingMessage } from "node:http";
|
import { IncomingMessage } from "node:http";
|
||||||
import { ServerOptions, WebSocket, WebSocketServer } from "ws";
|
import { ServerOptions, WebSocket, WebSocketServer } from "ws";
|
||||||
|
import type { RedisOptions } from "ioredis";
|
||||||
|
import {
|
||||||
|
RoomManager,
|
||||||
|
InMemoryRoomManager,
|
||||||
|
RedisRoomManager,
|
||||||
|
} from "./room-manager";
|
||||||
import { CodeError } from "../common/codeerror";
|
import { CodeError } from "../common/codeerror";
|
||||||
import { Command, parseCommand } from "../common/message";
|
import { Command, parseCommand } from "../common/message";
|
||||||
import { Status } from "../common/status";
|
import { Status } from "../common/status";
|
||||||
@ -34,6 +40,16 @@ export type KeepAliveServerOptions = ServerOptions & {
|
|||||||
* @default 5000
|
* @default 5000
|
||||||
*/
|
*/
|
||||||
latencyInterval?: number;
|
latencyInterval?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room backend type: "memory" (default) or "redis"
|
||||||
|
*/
|
||||||
|
roomBackend?: "memory" | "redis";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis options, required if roomBackend is "redis"
|
||||||
|
*/
|
||||||
|
redisOptions?: RedisOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class KeepAliveServer extends WebSocketServer {
|
export class KeepAliveServer extends WebSocketServer {
|
||||||
@ -44,7 +60,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
} = {};
|
} = {};
|
||||||
globalMiddlewares: SocketMiddleware[] = [];
|
globalMiddlewares: SocketMiddleware[] = [];
|
||||||
middlewares: { [key: string]: SocketMiddleware[] } = {};
|
middlewares: { [key: string]: SocketMiddleware[] } = {};
|
||||||
rooms: { [roomName: string]: Set<string> } = {};
|
roomManager: RoomManager;
|
||||||
serverOptions: ServerOptions & {
|
serverOptions: ServerOptions & {
|
||||||
pingInterval: number;
|
pingInterval: number;
|
||||||
latencyInterval: number;
|
latencyInterval: number;
|
||||||
@ -67,6 +83,23 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
latencyInterval: opts.latencyInterval ?? 5_000,
|
latencyInterval: opts.latencyInterval ?? 5_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Room manager selection
|
||||||
|
if (opts.roomBackend === "redis") {
|
||||||
|
if (!opts.redisOptions) {
|
||||||
|
throw new Error(
|
||||||
|
"redisOptions must be provided when roomBackend is 'redis'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.roomManager = new RedisRoomManager(
|
||||||
|
opts.redisOptions,
|
||||||
|
(id: string) => this.connections[id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.roomManager = new InMemoryRoomManager(
|
||||||
|
(id: string) => this.connections[id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.on("listening", () => {
|
this.on("listening", () => {
|
||||||
this._listening = true;
|
this._listening = true;
|
||||||
this.status = Status.ONLINE;
|
this.status = Status.ONLINE;
|
||||||
@ -80,14 +113,14 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
this.applyListeners();
|
this.applyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanupConnection(connection: Connection): void {
|
private async cleanupConnection(connection: Connection): Promise<void> {
|
||||||
connection.stopIntervals();
|
connection.stopIntervals();
|
||||||
delete this.connections[connection.id];
|
delete this.connections[connection.id];
|
||||||
|
|
||||||
if (this.remoteAddressToConnections[connection.remoteAddress]) {
|
if (this.remoteAddressToConnections[connection.remoteAddress]) {
|
||||||
this.remoteAddressToConnections[connection.remoteAddress] =
|
this.remoteAddressToConnections[connection.remoteAddress] =
|
||||||
this.remoteAddressToConnections[connection.remoteAddress].filter(
|
this.remoteAddressToConnections[connection.remoteAddress].filter(
|
||||||
(conn) => conn.id !== connection.id,
|
(conn) => conn.id !== connection.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -98,9 +131,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove from all rooms
|
// Remove from all rooms
|
||||||
Object.keys(this.rooms).forEach((roomName) => {
|
await this.roomManager.removeFromAllRooms(connection);
|
||||||
this.rooms[roomName].delete(connection.id);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyListeners(): void {
|
private applyListeners(): void {
|
||||||
@ -113,13 +144,13 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.remoteAddressToConnections[connection.remoteAddress].push(
|
this.remoteAddressToConnections[connection.remoteAddress].push(
|
||||||
connection,
|
connection
|
||||||
);
|
);
|
||||||
|
|
||||||
this.emit("connected", connection);
|
this.emit("connected", connection);
|
||||||
|
|
||||||
connection.on("close", () => {
|
connection.on("close", async () => {
|
||||||
this.cleanupConnection(connection);
|
await this.cleanupConnection(connection);
|
||||||
this.emit("close", connection);
|
this.emit("close", connection);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -137,7 +168,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
command.id,
|
command.id,
|
||||||
command.command,
|
command.command,
|
||||||
command.payload,
|
command.payload,
|
||||||
connection,
|
connection
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -172,7 +203,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
broadcastRemoteAddress(
|
broadcastRemoteAddress(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
command: string,
|
command: string,
|
||||||
payload: any,
|
payload: any
|
||||||
): void {
|
): void {
|
||||||
const cmd: Command = { command, payload };
|
const cmd: Command = { command, payload };
|
||||||
const connections =
|
const connections =
|
||||||
@ -194,47 +225,30 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
* Given a roomName, a command and a payload, broadcasts to all Connections
|
* Given a roomName, a command and a payload, broadcasts to all Connections
|
||||||
* that are in the room.
|
* that are in the room.
|
||||||
*/
|
*/
|
||||||
broadcastRoom(roomName: string, command: string, payload: any): void {
|
async broadcastRoom(
|
||||||
const cmd: Command = { command, payload };
|
roomName: string,
|
||||||
const room = this.rooms[roomName];
|
command: string,
|
||||||
|
payload: any
|
||||||
if (!room) return;
|
): Promise<void> {
|
||||||
|
await this.roomManager.broadcastRoom(roomName, command, payload);
|
||||||
room.forEach((connectionId) => {
|
|
||||||
const connection = this.connections[connectionId];
|
|
||||||
if (connection) {
|
|
||||||
connection.send(cmd);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a roomName, command, payload, and Connection OR Connection[], broadcasts to all Connections
|
* Given a roomName, command, payload, and Connection OR Connection[], broadcasts to all Connections
|
||||||
* that are in the room except the provided Connection(s).
|
* that are in the room except the provided Connection(s).
|
||||||
*/
|
*/
|
||||||
broadcastRoomExclude(
|
async broadcastRoomExclude(
|
||||||
roomName: string,
|
roomName: string,
|
||||||
command: string,
|
command: string,
|
||||||
payload: any,
|
payload: any,
|
||||||
connection: Connection | Connection[],
|
connection: Connection | Connection[]
|
||||||
): void {
|
): Promise<void> {
|
||||||
const cmd: Command = { command, payload };
|
await this.roomManager.broadcastRoomExclude(
|
||||||
const room = this.rooms[roomName];
|
roomName,
|
||||||
|
command,
|
||||||
if (!room) return;
|
payload,
|
||||||
|
connection
|
||||||
const excludeIds = Array.isArray(connection)
|
);
|
||||||
? connection.map((c) => c.id)
|
|
||||||
: [connection.id];
|
|
||||||
|
|
||||||
room.forEach((connectionId) => {
|
|
||||||
if (!excludeIds.includes(connectionId)) {
|
|
||||||
const conn = this.connections[connectionId];
|
|
||||||
if (conn) {
|
|
||||||
conn.send(cmd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -244,7 +258,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
broadcastExclude(
|
broadcastExclude(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
command: string,
|
command: string,
|
||||||
payload: any,
|
payload: any
|
||||||
): void {
|
): void {
|
||||||
const cmd: Command = { command, payload };
|
const cmd: Command = { command, payload };
|
||||||
|
|
||||||
@ -258,46 +272,39 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
/**
|
/**
|
||||||
* Add a connection to a room
|
* Add a connection to a room
|
||||||
*/
|
*/
|
||||||
addToRoom(roomName: string, connection: Connection): void {
|
async addToRoom(roomName: string, connection: Connection): Promise<void> {
|
||||||
this.rooms[roomName] = this.rooms[roomName] ?? new Set();
|
await this.roomManager.addToRoom(roomName, connection);
|
||||||
this.rooms[roomName].add(connection.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a connection from a room
|
* Remove a connection from a room
|
||||||
*/
|
*/
|
||||||
removeFromRoom(roomName: string, connection: Connection): void {
|
async removeFromRoom(
|
||||||
if (!this.rooms[roomName]) return;
|
roomName: string,
|
||||||
this.rooms[roomName].delete(connection.id);
|
connection: Connection
|
||||||
|
): Promise<void> {
|
||||||
|
await this.roomManager.removeFromRoom(roomName, connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a connection from all rooms
|
* Remove a connection from all rooms
|
||||||
*/
|
*/
|
||||||
removeFromAllRooms(connection: Connection | string): void {
|
async removeFromAllRooms(connection: Connection | string): Promise<void> {
|
||||||
const connectionId =
|
await this.roomManager.removeFromAllRooms(connection);
|
||||||
typeof connection === "string" ? connection : connection.id;
|
|
||||||
|
|
||||||
Object.keys(this.rooms).forEach((roomName) => {
|
|
||||||
this.rooms[roomName].delete(connectionId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all connections in a room
|
* Returns all connections in a room
|
||||||
*/
|
*/
|
||||||
getRoom(roomName: string): Connection[] {
|
async getRoom(roomName: string): Promise<Connection[]> {
|
||||||
const ids = this.rooms[roomName] || new Set();
|
return this.roomManager.getRoom(roomName);
|
||||||
return Array.from(ids)
|
|
||||||
.map((id) => this.connections[id])
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all connections from a room
|
* Clear all connections from a room
|
||||||
*/
|
*/
|
||||||
clearRoom(roomName: string): void {
|
async clearRoom(roomName: string): Promise<void> {
|
||||||
this.rooms[roomName] = new Set();
|
await this.roomManager.clearRoom(roomName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -306,7 +313,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
async registerCommand<T = any>(
|
async registerCommand<T = any>(
|
||||||
command: string,
|
command: string,
|
||||||
callback: (context: WSContext<any>) => Promise<T> | T,
|
callback: (context: WSContext<any>) => Promise<T> | T,
|
||||||
middlewares: SocketMiddleware[] = [],
|
middlewares: SocketMiddleware[] = []
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.commands[command] = callback;
|
this.commands[command] = callback;
|
||||||
|
|
||||||
@ -322,7 +329,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
*/
|
*/
|
||||||
prependMiddlewareToCommand(
|
prependMiddlewareToCommand(
|
||||||
command: string,
|
command: string,
|
||||||
middlewares: SocketMiddleware[],
|
middlewares: SocketMiddleware[]
|
||||||
): void {
|
): void {
|
||||||
if (middlewares.length) {
|
if (middlewares.length) {
|
||||||
this.middlewares[command] = this.middlewares[command] || [];
|
this.middlewares[command] = this.middlewares[command] || [];
|
||||||
@ -335,7 +342,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
*/
|
*/
|
||||||
appendMiddlewareToCommand(
|
appendMiddlewareToCommand(
|
||||||
command: string,
|
command: string,
|
||||||
middlewares: SocketMiddleware[],
|
middlewares: SocketMiddleware[]
|
||||||
): void {
|
): void {
|
||||||
if (middlewares.length) {
|
if (middlewares.length) {
|
||||||
this.middlewares[command] = this.middlewares[command] || [];
|
this.middlewares[command] = this.middlewares[command] || [];
|
||||||
@ -350,7 +357,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
id: number,
|
id: number,
|
||||||
command: string,
|
command: string,
|
||||||
payload: any,
|
payload: any,
|
||||||
connection: Connection,
|
connection: Connection
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const context = new WSContext(this, connection, payload);
|
const context = new WSContext(this, connection, payload);
|
||||||
|
|
||||||
@ -359,7 +366,7 @@ export class KeepAliveServer extends WebSocketServer {
|
|||||||
throw new CodeError(
|
throw new CodeError(
|
||||||
`Command [${command}] not found.`,
|
`Command [${command}] not found.`,
|
||||||
"ENOTFOUND",
|
"ENOTFOUND",
|
||||||
"CommandError",
|
"CommandError"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
192
packages/keepalive-ws/src/server/room-manager.ts
Normal file
192
packages/keepalive-ws/src/server/room-manager.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { Connection } from "./connection";
|
||||||
|
import Redis from "ioredis";
|
||||||
|
import type { RedisOptions } from "ioredis";
|
||||||
|
|
||||||
|
export interface RoomManager {
|
||||||
|
addToRoom(roomName: string, connection: Connection): Promise<void>;
|
||||||
|
removeFromRoom(roomName: string, connection: Connection): Promise<void>;
|
||||||
|
removeFromAllRooms(connection: Connection | string): Promise<void>;
|
||||||
|
getRoom(roomName: string): Promise<Connection[]>;
|
||||||
|
clearRoom(roomName: string): Promise<void>;
|
||||||
|
broadcastRoom(roomName: string, command: string, payload: any): Promise<void>;
|
||||||
|
broadcastRoomExclude(
|
||||||
|
roomName: string,
|
||||||
|
command: string,
|
||||||
|
payload: any,
|
||||||
|
connection: Connection | Connection[]
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemoryRoomManager implements RoomManager {
|
||||||
|
private rooms: { [roomName: string]: Set<string> } = {};
|
||||||
|
private getConnectionById: (id: string) => Connection | undefined;
|
||||||
|
|
||||||
|
constructor(getConnectionById: (id: string) => Connection | undefined) {
|
||||||
|
this.getConnectionById = getConnectionById;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addToRoom(roomName: string, connection: Connection): Promise<void> {
|
||||||
|
this.rooms[roomName] = this.rooms[roomName] ?? new Set();
|
||||||
|
this.rooms[roomName].add(connection.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFromRoom(
|
||||||
|
roomName: string,
|
||||||
|
connection: Connection
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.rooms[roomName]) return;
|
||||||
|
this.rooms[roomName].delete(connection.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFromAllRooms(connection: Connection | string): Promise<void> {
|
||||||
|
const connectionId =
|
||||||
|
typeof connection === "string" ? connection : connection.id;
|
||||||
|
Object.keys(this.rooms).forEach((roomName) => {
|
||||||
|
this.rooms[roomName].delete(connectionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoom(roomName: string): Promise<Connection[]> {
|
||||||
|
const ids = this.rooms[roomName] || new Set();
|
||||||
|
return Array.from(ids)
|
||||||
|
.map((id) => this.getConnectionById(id))
|
||||||
|
.filter(Boolean) as Connection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearRoom(roomName: string): Promise<void> {
|
||||||
|
this.rooms[roomName] = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastRoom(
|
||||||
|
roomName: string,
|
||||||
|
command: string,
|
||||||
|
payload: any
|
||||||
|
): Promise<void> {
|
||||||
|
const ids = this.rooms[roomName];
|
||||||
|
if (!ids) return;
|
||||||
|
for (const connectionId of ids) {
|
||||||
|
const connection = this.getConnectionById(connectionId);
|
||||||
|
if (connection) {
|
||||||
|
connection.send({ command, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastRoomExclude(
|
||||||
|
roomName: string,
|
||||||
|
command: string,
|
||||||
|
payload: any,
|
||||||
|
connection: Connection | Connection[]
|
||||||
|
): Promise<void> {
|
||||||
|
const ids = this.rooms[roomName];
|
||||||
|
if (!ids) return;
|
||||||
|
const excludeIds = Array.isArray(connection)
|
||||||
|
? connection.map((c) => c.id)
|
||||||
|
: [connection.id];
|
||||||
|
for (const connectionId of ids) {
|
||||||
|
if (!excludeIds.includes(connectionId)) {
|
||||||
|
const conn = this.getConnectionById(connectionId);
|
||||||
|
if (conn) {
|
||||||
|
conn.send({ command, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedisRoomManager implements RoomManager {
|
||||||
|
private redis: Redis;
|
||||||
|
private getConnectionById: (id: string) => Connection | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
redisOptions: RedisOptions,
|
||||||
|
getConnectionById: (id: string) => Connection | undefined
|
||||||
|
) {
|
||||||
|
this.redis = new Redis(redisOptions);
|
||||||
|
this.getConnectionById = getConnectionById;
|
||||||
|
// TODO: reconnect logic?
|
||||||
|
}
|
||||||
|
|
||||||
|
private roomKey(roomName: string) {
|
||||||
|
return `room:${roomName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private connRoomsKey(connectionId: string) {
|
||||||
|
return `connection:${connectionId}:rooms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addToRoom(roomName: string, connection: Connection): Promise<void> {
|
||||||
|
await this.redis.sadd(this.roomKey(roomName), connection.id);
|
||||||
|
await this.redis.sadd(this.connRoomsKey(connection.id), roomName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFromRoom(
|
||||||
|
roomName: string,
|
||||||
|
connection: Connection
|
||||||
|
): Promise<void> {
|
||||||
|
await this.redis.srem(this.roomKey(roomName), connection.id);
|
||||||
|
await this.redis.srem(this.connRoomsKey(connection.id), roomName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFromAllRooms(connection: Connection | string): Promise<void> {
|
||||||
|
const connectionId =
|
||||||
|
typeof connection === "string" ? connection : connection.id;
|
||||||
|
const roomNames = await this.redis.smembers(
|
||||||
|
this.connRoomsKey(connectionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(roomNames.length > 0)) return;
|
||||||
|
|
||||||
|
const pipeline = this.redis.pipeline();
|
||||||
|
for (const roomName of roomNames) {
|
||||||
|
pipeline.srem(this.roomKey(roomName), connectionId);
|
||||||
|
}
|
||||||
|
pipeline.del(this.connRoomsKey(connectionId));
|
||||||
|
await pipeline.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoom(roomName: string): Promise<Connection[]> {
|
||||||
|
const ids = await this.redis.smembers(this.roomKey(roomName));
|
||||||
|
return ids
|
||||||
|
.map((id) => this.getConnectionById(id))
|
||||||
|
.filter(Boolean) as Connection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearRoom(roomName: string): Promise<void> {
|
||||||
|
await this.redis.del(this.roomKey(roomName));
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastRoom(
|
||||||
|
roomName: string,
|
||||||
|
command: string,
|
||||||
|
payload: any
|
||||||
|
): Promise<void> {
|
||||||
|
const ids = await this.redis.smembers(this.roomKey(roomName));
|
||||||
|
for (const connectionId of ids) {
|
||||||
|
const connection = this.getConnectionById(connectionId);
|
||||||
|
if (connection) {
|
||||||
|
connection.send({ command, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastRoomExclude(
|
||||||
|
roomName: string,
|
||||||
|
command: string,
|
||||||
|
payload: any,
|
||||||
|
connection: Connection | Connection[]
|
||||||
|
): Promise<void> {
|
||||||
|
const ids = await this.redis.smembers(this.roomKey(roomName));
|
||||||
|
const excludeIds = Array.isArray(connection)
|
||||||
|
? connection.map((c) => c.id)
|
||||||
|
: [connection.id];
|
||||||
|
for (const connectionId of ids) {
|
||||||
|
if (!excludeIds.includes(connectionId)) {
|
||||||
|
const conn = this.getConnectionById(connectionId);
|
||||||
|
if (conn) {
|
||||||
|
conn.send({ command, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,13 +2,12 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import { KeepAliveClient, Status } from "../src/client/client";
|
import { KeepAliveClient, Status } from "../src/client/client";
|
||||||
import { KeepAliveServer } from "../src/server/index";
|
import { KeepAliveServer } from "../src/server/index";
|
||||||
|
|
||||||
const createTestServer = (port: number) => {
|
const createTestServer = (port: number) =>
|
||||||
return new KeepAliveServer({
|
new KeepAliveServer({
|
||||||
port,
|
port,
|
||||||
pingInterval: 1000,
|
pingInterval: 1000,
|
||||||
latencyInterval: 500,
|
latencyInterval: 500,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
|
describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
|
||||||
const port = 8125;
|
const port = 8125;
|
||||||
@ -49,14 +48,15 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("command times out when server doesn't respond", async () => {
|
test("command times out when server doesn't respond", async () => {
|
||||||
await server.registerCommand("never-responds", async () => {
|
await server.registerCommand(
|
||||||
return new Promise(() => {});
|
"never-responds",
|
||||||
});
|
async () => new Promise(() => {})
|
||||||
|
);
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.command("never-responds", "Should timeout", 500),
|
client.command("never-responds", "Should timeout", 500)
|
||||||
).rejects.toThrow(/timed out/);
|
).rejects.toThrow(/timed out/);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
@ -82,9 +82,10 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
|
|||||||
return `Slow: ${context.payload}`;
|
return `Slow: ${context.payload}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.registerCommand("echo", async (context) => {
|
await server.registerCommand(
|
||||||
return `Echo: ${context.payload}`;
|
"echo",
|
||||||
});
|
async (context) => `Echo: ${context.payload}`
|
||||||
|
);
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
@ -98,9 +99,7 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
test("handles large payloads correctly", async () => {
|
test("handles large payloads correctly", async () => {
|
||||||
await server.registerCommand("echo", async (context) => {
|
await server.registerCommand("echo", async (context) => context.payload);
|
||||||
return context.payload;
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
@ -123,9 +122,10 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
test("server handles multiple client connections", async () => {
|
test("server handles multiple client connections", async () => {
|
||||||
await server.registerCommand("echo", async (context) => {
|
await server.registerCommand(
|
||||||
return `Echo: ${context.payload}`;
|
"echo",
|
||||||
});
|
async (context) => `Echo: ${context.payload}`
|
||||||
|
);
|
||||||
|
|
||||||
const clients = Array(5)
|
const clients = Array(5)
|
||||||
.fill(0)
|
.fill(0)
|
||||||
@ -134,7 +134,7 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
|
|||||||
await Promise.all(clients.map((client) => client.connect()));
|
await Promise.all(clients.map((client) => client.connect()));
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
clients.map((client, i) => client.command("echo", `Client ${i}`, 1000)),
|
clients.map((client, i) => client.command("echo", `Client ${i}`, 1000))
|
||||||
);
|
);
|
||||||
|
|
||||||
results.forEach((result, i) => {
|
results.forEach((result, i) => {
|
||||||
|
|||||||
@ -2,13 +2,12 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import { KeepAliveClient, Status } from "../src/client/client";
|
import { KeepAliveClient, Status } from "../src/client/client";
|
||||||
import { KeepAliveServer } from "../src/server/index";
|
import { KeepAliveServer } from "../src/server/index";
|
||||||
|
|
||||||
const createTestServer = (port: number) => {
|
const createTestServer = (port: number) =>
|
||||||
return new KeepAliveServer({
|
new KeepAliveServer({
|
||||||
port,
|
port,
|
||||||
pingInterval: 1000,
|
pingInterval: 1000,
|
||||||
latencyInterval: 500,
|
latencyInterval: 500,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
describe("Basic KeepAliveClient and KeepAliveServer Tests", () => {
|
describe("Basic KeepAliveClient and KeepAliveServer Tests", () => {
|
||||||
const port = 8124;
|
const port = 8124;
|
||||||
@ -48,18 +47,17 @@ describe("Basic KeepAliveClient and KeepAliveServer Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("client-server connection should be online", async () => {
|
test("client-server connection should be online", async () => {
|
||||||
await server.registerCommand("echo", async (context) => {
|
await server.registerCommand("echo", async (context) => context.payload);
|
||||||
return context.payload;
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
expect(client.status).toBe(Status.ONLINE);
|
expect(client.status).toBe(Status.ONLINE);
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
test("simple echo command", async () => {
|
test("simple echo command", async () => {
|
||||||
await server.registerCommand("echo", async (context) => {
|
await server.registerCommand(
|
||||||
return `Echo: ${context.payload}`;
|
"echo",
|
||||||
});
|
async (context) => `Echo: ${context.payload}`
|
||||||
|
);
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
@ -68,9 +66,7 @@ describe("Basic KeepAliveClient and KeepAliveServer Tests", () => {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
test("connect should resolve when already connected", async () => {
|
test("connect should resolve when already connected", async () => {
|
||||||
await server.registerCommand("echo", async (context) => {
|
await server.registerCommand("echo", async (context) => context.payload);
|
||||||
return context.payload;
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
expect(client.status).toBe(Status.ONLINE);
|
expect(client.status).toBe(Status.ONLINE);
|
||||||
|
|||||||
343
packages/keepalive-ws/tests/redis-room.test.ts
Normal file
343
packages/keepalive-ws/tests/redis-room.test.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import Redis from "ioredis";
|
||||||
|
import { KeepAliveClient, Status } from "../src/client/client";
|
||||||
|
import { KeepAliveServer } from "../src/server/index";
|
||||||
|
|
||||||
|
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 createRedisServer = (port: number) =>
|
||||||
|
new KeepAliveServer({
|
||||||
|
port,
|
||||||
|
pingInterval: 1000,
|
||||||
|
latencyInterval: 500,
|
||||||
|
roomBackend: "redis",
|
||||||
|
redisOptions: { host: REDIS_HOST, port: REDIS_PORT },
|
||||||
|
});
|
||||||
|
|
||||||
|
const flushRedis = async () => {
|
||||||
|
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
|
||||||
|
await redis.flushdb();
|
||||||
|
await redis.quit();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("KeepAliveServer with Redis room backend", () => {
|
||||||
|
const port = 8126;
|
||||||
|
let server: KeepAliveServer;
|
||||||
|
let clientA: KeepAliveClient;
|
||||||
|
let clientB: KeepAliveClient;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await flushRedis();
|
||||||
|
|
||||||
|
server = createRedisServer(port);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.on("listening", () => resolve());
|
||||||
|
if (server.listening) resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
clientA = new KeepAliveClient(`ws://localhost:${port}`);
|
||||||
|
clientB = new KeepAliveClient(`ws://localhost:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (clientA.status === Status.ONLINE) await clientA.close();
|
||||||
|
if (clientB.status === Status.ONLINE) await clientB.close();
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (server) {
|
||||||
|
server.close(() => resolve());
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multi-instance room membership and broadcast with Redis", async () => {
|
||||||
|
await server.registerCommand("join-room", async (context) => {
|
||||||
|
await server.addToRoom(context.payload.room, context.connection);
|
||||||
|
return { joined: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.registerCommand("broadcast-room", async (context) => {
|
||||||
|
await server.broadcastRoom(
|
||||||
|
context.payload.room,
|
||||||
|
"room-message",
|
||||||
|
context.payload.message
|
||||||
|
);
|
||||||
|
return { sent: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.connect();
|
||||||
|
await clientB.connect();
|
||||||
|
|
||||||
|
let receivedA = false;
|
||||||
|
let receivedB = false;
|
||||||
|
|
||||||
|
clientA.on("room-message", (data) => {
|
||||||
|
if (data === "hello") receivedA = true;
|
||||||
|
});
|
||||||
|
clientB.on("room-message", (data) => {
|
||||||
|
if (data === "hello") receivedB = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.command("join-room", { room: "testroom" });
|
||||||
|
await clientB.command("join-room", { room: "testroom" });
|
||||||
|
|
||||||
|
await clientA.command("broadcast-room", {
|
||||||
|
room: "testroom",
|
||||||
|
message: "hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for both events or timeout
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(resolve, 2000);
|
||||||
|
const check = () => {
|
||||||
|
if (receivedA && receivedB) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
clientA.on("room-message", check);
|
||||||
|
clientB.on("room-message", check);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receivedA).toBe(true);
|
||||||
|
expect(receivedB).toBe(true);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
test("removeFromRoom removes a client from a specific room", async () => {
|
||||||
|
await server.registerCommand("join-room", async (context) => {
|
||||||
|
await server.addToRoom(context.payload.room, context.connection);
|
||||||
|
return { joined: true };
|
||||||
|
});
|
||||||
|
await server.registerCommand("leave-room", async (context) => {
|
||||||
|
await server.removeFromRoom(context.payload.room, context.connection);
|
||||||
|
return { left: true };
|
||||||
|
});
|
||||||
|
await server.registerCommand("broadcast-room", async (context) => {
|
||||||
|
await server.broadcastRoom(
|
||||||
|
context.payload.room,
|
||||||
|
"room-message",
|
||||||
|
context.payload.message
|
||||||
|
);
|
||||||
|
return { sent: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.connect();
|
||||||
|
await clientB.connect();
|
||||||
|
|
||||||
|
let receivedA = false;
|
||||||
|
let receivedB = false;
|
||||||
|
|
||||||
|
clientA.on("room-message", (data) => {
|
||||||
|
if (data === "hello after leave") receivedA = true;
|
||||||
|
});
|
||||||
|
clientB.on("room-message", (data) => {
|
||||||
|
if (data === "hello after leave") receivedB = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.command("join-room", { room: "testroom-leave" });
|
||||||
|
await clientB.command("join-room", { room: "testroom-leave" });
|
||||||
|
|
||||||
|
// Ensure both are in before leaving
|
||||||
|
await new Promise((res) => setTimeout(res, 100)); // Short delay for redis propagation
|
||||||
|
|
||||||
|
await clientA.command("leave-room", { room: "testroom-leave" });
|
||||||
|
|
||||||
|
// Wait a bit for leave command to process
|
||||||
|
await new Promise((res) => setTimeout(res, 100));
|
||||||
|
|
||||||
|
await clientB.command("broadcast-room", {
|
||||||
|
room: "testroom-leave",
|
||||||
|
message: "hello after leave",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for potential message or timeout
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
expect(receivedA).toBe(false); // Client A should not receive the message
|
||||||
|
expect(receivedB).toBe(true); // Client B should receive the message
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
test("removeFromAllRooms removes a client from all rooms", async () => {
|
||||||
|
await server.registerCommand("join-room", async (context) => {
|
||||||
|
await server.addToRoom(context.payload.room, context.connection);
|
||||||
|
return { joined: true };
|
||||||
|
});
|
||||||
|
await server.registerCommand("leave-all-rooms", async (context) => {
|
||||||
|
await server.removeFromAllRooms(context.connection);
|
||||||
|
return { left_all: true };
|
||||||
|
});
|
||||||
|
await server.registerCommand("broadcast-room", async (context) => {
|
||||||
|
await server.broadcastRoom(
|
||||||
|
context.payload.room,
|
||||||
|
"room-message",
|
||||||
|
context.payload.message
|
||||||
|
);
|
||||||
|
return { sent: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.connect();
|
||||||
|
await clientB.connect();
|
||||||
|
|
||||||
|
let receivedA_room1 = false;
|
||||||
|
let receivedA_room2 = false;
|
||||||
|
let receivedB_room1 = false;
|
||||||
|
|
||||||
|
clientA.on("room-message", (data) => {
|
||||||
|
if (data === "hello room1 after all") receivedA_room1 = true;
|
||||||
|
if (data === "hello room2 after all") receivedA_room2 = true;
|
||||||
|
});
|
||||||
|
clientB.on("room-message", (data) => {
|
||||||
|
if (data === "hello room1 after all") receivedB_room1 = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.command("join-room", { room: "room1" });
|
||||||
|
await clientA.command("join-room", { room: "room2" });
|
||||||
|
await clientB.command("join-room", { room: "room1" });
|
||||||
|
|
||||||
|
// Ensure joins are processed
|
||||||
|
await new Promise((res) => setTimeout(res, 100));
|
||||||
|
|
||||||
|
await clientA.command("leave-all-rooms", {});
|
||||||
|
|
||||||
|
// Wait a bit for leave command to process
|
||||||
|
await new Promise((res) => setTimeout(res, 100));
|
||||||
|
|
||||||
|
// Broadcast to room1
|
||||||
|
await clientB.command("broadcast-room", {
|
||||||
|
room: "room1",
|
||||||
|
message: "hello room1 after all",
|
||||||
|
});
|
||||||
|
// Broadcast to room2 (no one should be left)
|
||||||
|
await clientB.command("broadcast-room", {
|
||||||
|
// Client B isn't in room2, but can still broadcast
|
||||||
|
room: "room2",
|
||||||
|
message: "hello room2 after all",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for potential messages or timeout
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
expect(receivedA_room1).toBe(false); // Client A should not receive from room1
|
||||||
|
expect(receivedA_room2).toBe(false); // Client A should not receive from room2
|
||||||
|
expect(receivedB_room1).toBe(true); // Client B should receive from room1
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
test("clearRoom removes all clients from a room", async () => {
|
||||||
|
await server.registerCommand("join-room", async (context) => {
|
||||||
|
await server.addToRoom(context.payload.room, context.connection);
|
||||||
|
return { joined: true };
|
||||||
|
});
|
||||||
|
await server.registerCommand("clear-room", async (context) => {
|
||||||
|
await server.clearRoom(context.payload.room);
|
||||||
|
return { cleared: true };
|
||||||
|
});
|
||||||
|
await server.registerCommand("broadcast-room", async (context) => {
|
||||||
|
await server.broadcastRoom(
|
||||||
|
context.payload.room,
|
||||||
|
"room-message",
|
||||||
|
context.payload.message
|
||||||
|
);
|
||||||
|
return { sent: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.connect();
|
||||||
|
await clientB.connect();
|
||||||
|
|
||||||
|
let receivedA = false;
|
||||||
|
let receivedB = false;
|
||||||
|
|
||||||
|
clientA.on("room-message", (data) => {
|
||||||
|
if (data === "hello after clear") receivedA = true;
|
||||||
|
});
|
||||||
|
clientB.on("room-message", (data) => {
|
||||||
|
if (data === "hello after clear") receivedB = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.command("join-room", { room: "testroom-clear" });
|
||||||
|
await clientB.command("join-room", { room: "testroom-clear" });
|
||||||
|
|
||||||
|
// Ensure joins are processed
|
||||||
|
await new Promise((res) => setTimeout(res, 100));
|
||||||
|
|
||||||
|
await clientA.command("clear-room", { room: "testroom-clear" });
|
||||||
|
|
||||||
|
// Wait a bit for clear command to process
|
||||||
|
await new Promise((res) => setTimeout(res, 100));
|
||||||
|
|
||||||
|
// Try broadcasting (client A is still connected, just not in room)
|
||||||
|
await clientA.command("broadcast-room", {
|
||||||
|
room: "testroom-clear",
|
||||||
|
message: "hello after clear",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for potential messages or timeout
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
expect(receivedA).toBe(false); // Client A should not receive
|
||||||
|
expect(receivedB).toBe(false); // Client B should not receive
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
test("broadcastRoomExclude sends to all except specified clients", async () => {
|
||||||
|
const clientC = new KeepAliveClient(`ws://localhost:${port}`);
|
||||||
|
|
||||||
|
await server.registerCommand("join-room", async (context) => {
|
||||||
|
await server.addToRoom(context.payload.room, context.connection);
|
||||||
|
return { joined: true };
|
||||||
|
});
|
||||||
|
await server.registerCommand("broadcast-exclude", async (context) => {
|
||||||
|
await server.broadcastRoomExclude(
|
||||||
|
context.payload.room,
|
||||||
|
"room-message",
|
||||||
|
context.payload.message,
|
||||||
|
context.connection // Exclude sender
|
||||||
|
);
|
||||||
|
return { sent_exclude: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.connect();
|
||||||
|
await clientB.connect();
|
||||||
|
await clientC.connect();
|
||||||
|
|
||||||
|
let receivedA = false;
|
||||||
|
let receivedB = false;
|
||||||
|
let receivedC = false;
|
||||||
|
|
||||||
|
clientA.on("room-message", (data) => {
|
||||||
|
if (data === "hello exclude") receivedA = true;
|
||||||
|
});
|
||||||
|
clientB.on("room-message", (data) => {
|
||||||
|
if (data === "hello exclude") receivedB = true;
|
||||||
|
});
|
||||||
|
clientC.on("room-message", (data) => {
|
||||||
|
if (data === "hello exclude") receivedC = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await clientA.command("join-room", { room: "testroom-exclude" });
|
||||||
|
await clientB.command("join-room", { room: "testroom-exclude" });
|
||||||
|
await clientC.command("join-room", { room: "testroom-exclude" });
|
||||||
|
|
||||||
|
// Ensure joins are processed
|
||||||
|
await new Promise((res) => setTimeout(res, 100));
|
||||||
|
|
||||||
|
// Client A broadcasts, excluding itself
|
||||||
|
await clientA.command("broadcast-exclude", {
|
||||||
|
room: "testroom-exclude",
|
||||||
|
message: "hello exclude",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for potential messages or timeout
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
expect(receivedA).toBe(false); // Client A (sender) should not receive
|
||||||
|
expect(receivedB).toBe(true); // Client B should receive
|
||||||
|
expect(receivedC).toBe(true); // Client C should receive
|
||||||
|
|
||||||
|
if (clientC.status === Status.ONLINE) await clientC.close();
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user