UNPKG

actionhero

Version:

The reusable, scalable, and quick node.js API server for stateless and stateful applications

322 lines (289 loc) 9.71 kB
import { AsyncReturnType } from "type-fest"; import { api, config, id, redis, Connection } from "./../index"; import * as RedisModule from "../modules/redis"; export namespace chatRoom { /** * Middleware definition for processing chat events. Can be of the * * ```js * var chatMiddleware = { * name: 'chat middleware', * priority: 1000, * join: (connection, room) => { * // announce all connections entering a room * api.chatRoom.broadcast(null, room, 'I have joined the room: ' + connection.id, callback) * }, * leave:(connection, room, callback) => { * // announce all connections leaving a room * api.chatRoom.broadcast(null, room, 'I have left the room: ' + connection.id, callback) * }, * // Will be executed once per client connection before delivering the message. * say: (connection, room, messagePayload) => { * // do stuff * log(messagePayload) * }, * // Will be executed only once, when the message is sent to the server. * onSayReceive: (connection, room, messagePayload) => { * // do stuff * log(messagePayload) * } * } * api.chatRoom.addMiddleware(chatMiddleware) * ``` */ export interface ChatMiddleware { /**Unique name for the middleware. */ name: string; /**Module load order. Defaults to `api.config.general.defaultMiddlewarePriority`. */ priority?: number; /**Called when a connection joins a room. */ join?: Function; /**Called when a connection leaves a room. */ leave?: Function; /**Called when a connection says a message to a room. */ onSayReceive?: Function; /**Called when a connection is about to receive a say message. */ say?: Function; } export interface ChatPubSubMessage extends RedisModule.redis.PubSubMessage { messageType: string; serverToken: string; serverId: string | number; message: any; sentAt: number; connection: { id: string; room: string; }; } export function client() { if (api.redis.clients && api.redis.clients.client) { return api.redis.clients.client; } else { throw new Error("redis not connected, chatRoom cannot be used"); } } /** * Add a middleware component to connection handling. */ export async function addMiddleware(data: ChatMiddleware) { if (!data.name) { throw new Error("middleware.name is required"); } if (!data.priority) { data.priority = config.general.defaultMiddlewarePriority; } data.priority = Number(data.priority); api.chatRoom.middleware[data.name] = data; api.chatRoom.globalMiddleware.push(data.name); api.chatRoom.globalMiddleware.sort((a, b) => { if ( api.chatRoom.middleware[a].priority > api.chatRoom.middleware[b].priority ) { return 1; } else { return -1; } }); } /** * List all chat rooms created */ export async function list(): Promise<Array<string>> { return client().smembers(api.chatRoom.keys.rooms); } /** * Add a new chat room. Throws an error if the room already exists. */ export async function add(room: string) { const found = await chatRoom.exists(room); if (found === false) { return client().sadd(api.chatRoom.keys.rooms, room); } else { throw new Error(await config.errors.connectionRoomExists(room)); } } /** * Remove an existing chat room. All connections in the room will be removed. Throws an error if the room does not exist. */ export async function destroy(room: string) { const found = await chatRoom.exists(room); if (found === true) { await api.chatRoom.broadcast( null, room, await config.errors.connectionRoomHasBeenDeleted(room), ); const membersHash = await client().hgetall( api.chatRoom.keys.members + room, ); for (const id in membersHash) { await chatRoom.removeMember(id, room, false); } await client().srem(api.chatRoom.keys.rooms, room); await client().del(api.chatRoom.keys.members + room); } else { throw new Error(await config.errors.connectionRoomNotExist(room)); } } /** * Check if a room exists. */ export async function exists(room: string): Promise<boolean> { const isMember = await client().sismember(api.chatRoom.keys.rooms, room); let found = false; if (isMember === 1 || isMember.toString() === "true") found = true; return found; } /** * Configures what properties of connections in a room to return via `api.chatRoom.roomStatus` */ export async function sanitizeMemberDetails(memberData: { id: string; joinedAt: number; [key: string]: any; }) { return { id: memberData.id, joinedAt: memberData.joinedAt, }; } /** * Learn about the connections in the room. * Returns a hash of the form { room: room, members: cleanedMembers, membersCount: count }. Members is an array of connections in the room sanitized via `api.chatRoom.sanitizeMemberDetails` */ export async function roomStatus(room: string): Promise<{ room: string; membersCount: number; members: Record<string, AsyncReturnType<typeof sanitizeMemberDetails>>; }> { if (room) { const found = await chatRoom.exists(room); if (found === true) { const key = api.chatRoom.keys.members + room; const members = (await api.redis.clients.client.hgetall(key)) as { [key: string]: string; }; const cleanedMembers: Record<string, any> = {}; let count = 0; for (const id in members) { const data = JSON.parse(members[id]); cleanedMembers[id] = await chatRoom.sanitizeMemberDetails(data); count++; } return { room: room, members: cleanedMembers, membersCount: count, }; } else { throw new Error(await config.errors.connectionRoomNotExist(room)); } } else { throw new Error(await config.errors.connectionRoomRequired()); } } /** * An overwrite-able method which configures what properties of connections in a room are initially stored about a connection when added via `api.chatRoom.addMember` */ export async function generateMemberDetails(connection: Connection) { return { id: connection.id, joinedAt: new Date().getTime(), host: id, }; } /** * Add a connection (via id) to a room. Throws errors if the room does not exist, or the connection is already in the room. Middleware errors also throw. */ export async function addMember( connectionId: string, room: string, ): Promise<any> { const connection = api.connections.connections[connectionId]; if (!connection) { return redis.doCluster( "api.chatRoom.addMember", [connectionId, room], connectionId, true, ); } if (connection.rooms.includes(room)) { throw new Error( await config.errors.connectionAlreadyInRoom(connection, room), ); } if (!connection.rooms.includes(room)) { const found = await chatRoom.exists(room); if (!found) { throw new Error(await config.errors.connectionRoomNotExist(room)); } await api.chatRoom.runMiddleware(connection, room, "join"); if (!connection.rooms.includes(room)) { connection.rooms.push(room); } const memberDetails = await chatRoom.generateMemberDetails(connection); await client().hset( api.chatRoom.keys.members + room, connection.id, JSON.stringify(memberDetails), ); } return true; } /** * Remote a connection (via id) from a room. Throws errors if the room does not exist, or the connection is not in the room. Middleware errors also throw. * toWaitRemote: Should this method wait until the remote Actionhero server (the one the connection is connected too) responds? */ export async function removeMember( connectionId: string, room: string, toWaitRemote: boolean = true, ): Promise<any> { const connection = api.connections.connections[connectionId]; if (!connection) { return redis.doCluster( "api.chatRoom.removeMember", [connectionId, room], connectionId, toWaitRemote, ); } if (!connection.rooms.includes(room)) { throw new Error( await config.errors.connectionNotInRoom(connection, room), ); } if (connection.rooms.includes(room)) { const found = await chatRoom.exists(room); if (!found) { throw new Error(await config.errors.connectionRoomNotExist(room)); } await api.chatRoom.runMiddleware(connection, room, "leave"); if (connection.rooms.includes(room)) { const index = connection.rooms.indexOf(room); connection.rooms.splice(index, 1); } await client().hdel(api.chatRoom.keys.members + room, connection.id); } return true; } /** * Send a message to all clients connected to this room * - connection: * - {} send to every connections * - should either be a real client you are emulating (found in api.connections) * - a mock * - room is the string name of an already-existing room * - message can be anything: string, json, object, etc */ export async function broadcast( connection: Partial<Connection>, room: string, message: any, ) { return api.chatRoom.broadcast(connection, room, message); } }