actionhero
Version:
The reusable, scalable, and quick node.js API server for stateless and stateful applications
322 lines (289 loc) • 9.71 kB
text/typescript
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);
}
}