actionhero
Version:
The reusable, scalable, and quick node.js API server for stateless and stateful applications
305 lines (267 loc) • 7.8 kB
text/typescript
import * as Primus from "primus";
import * as fs from "fs";
import * as path from "path";
import * as util from "util";
import * as uuid from "uuid";
import { api, config, utils, log, Server, Connection } from "../index";
export class WebSocketServer extends Server {
server: Primus;
constructor() {
super();
this.type = "websocket";
this.attributes = {
canChat: true,
logConnections: true,
logExits: true,
sendWelcomeMessage: true,
verbs: [
"quit",
"exit",
"documentation",
"roomAdd",
"roomLeave",
"roomView",
"detailsView",
"say",
],
};
}
async initialize() {
// we rely on the web server :D
}
async start() {
const webserver = api.servers.servers.web;
if (!webserver) {
throw new Error(`websocket server requires web server to be enabled`);
}
this.server = new Primus(webserver.server, this.config.server);
this.writeClientJS();
this.server.on("connection", (rawConnection) => {
this.handleConnection(rawConnection);
});
this.server.on("disconnection", (rawConnection) => {
this.handleDisconnection(rawConnection);
});
this.log(
`webSockets bound to ${webserver.config.bindIP}:${webserver.config.port}`,
"debug",
);
this.on("connection", (connection: Connection) => {
connection.rawConnection.on("data", (data: Record<string, any>) => {
this.handleData(connection, data);
});
});
this.on("actionComplete", (data) => {
if (data.toRender !== false) {
data.connection.response.messageId = data.messageId;
this.sendMessage(data.connection, data.response, data.messageId);
}
});
}
async stop() {
if (!this.server) return;
if (this.config.destroyClientsOnShutdown === true) {
this.connections().forEach((connection: Connection) => {
connection.destroy();
});
}
//@ts-ignore
this.server.destroy();
}
async sendMessage(
connection: Connection,
message: Record<string, any>,
messageId: string,
) {
if (message.error) {
message.error = config.errors.serializers.servers.websocket(
message.error,
);
}
if (!message.context) {
message.context = "response";
}
if (!messageId) {
messageId = connection.messageId;
}
if (message.context === "response" && !message.messageId) {
message.messageId = messageId;
}
connection.rawConnection.write(message);
}
async sendFile(
connection: Connection,
error: NodeJS.ErrnoException,
fileStream: any,
mime: string,
length: number,
lastModified: Date,
) {
const messageId = connection.messageId;
let content = "";
const response = {
error: error,
content: null as string,
mime: mime,
length: length,
lastModified: lastModified,
};
try {
if (!error) {
fileStream.on("data", (d: string) => {
content += d;
});
fileStream.on("end", () => {
response.content = content;
this.sendMessage(connection, response, messageId);
});
} else {
this.sendMessage(connection, response, messageId);
}
} catch (e) {
this.log(e, "warning");
this.sendMessage(connection, response, messageId);
}
}
//@ts-ignore
goodbye(connection: Connection) {
connection.rawConnection.end();
}
compileActionheroWebsocketClientJS() {
let ahClientSource = fs
.readFileSync(
path.join(__dirname, "/../../client/ActionheroWebsocketClient.js"),
)
.toString();
const url = this.config.clientUrl;
ahClientSource = ahClientSource.replace(/%%URL%%/g, url);
const defaults: {
[key: string]: any;
} = {};
for (const i in this.config.client) {
defaults[i] = this.config.client[i];
}
defaults.url = url;
let defaultsString = util.inspect(defaults);
defaultsString = defaultsString.replace(
"'window.location.origin'",
"window.location.origin",
);
ahClientSource = ahClientSource.replace(
"%%DEFAULTS%%",
"return " + defaultsString,
);
return ahClientSource;
}
renderClientJS() {
const libSource = api.servers.servers.websocket.server.library();
let ahClientSource = this.compileActionheroWebsocketClientJS();
ahClientSource =
";;;\r\n" +
"(function(exports){ \r\n" +
ahClientSource +
"\r\n" +
"exports.ActionheroWebsocketClient = ActionheroWebsocketClient; \r\n" +
"exports.ActionheroWebsocketClient = ActionheroWebsocketClient; \r\n" +
"})(typeof exports === 'undefined' ? window : exports);";
return libSource + "\r\n\r\n\r\n" + ahClientSource;
}
writeClientJS() {
if (
!config.general.paths.public ||
config.general.paths.public.length === 0
) {
return;
}
if (this.config.clientJsPath && this.config.clientJsName) {
const clientJSPath = path.normalize(
config.general.paths.public[0] +
path.sep +
this.config.clientJsPath +
path.sep,
);
const clientJSName = this.config.clientJsName;
const clientJSFullPath = clientJSPath + clientJSName;
try {
if (!fs.existsSync(clientJSPath)) {
fs.mkdirSync(clientJSPath, { recursive: true });
}
fs.writeFileSync(clientJSFullPath + ".js", this.renderClientJS());
log(`wrote ${clientJSFullPath}.js`, "debug");
} catch (e) {
log("Cannot write client-side JS for websocket server:", "alert", e);
throw e;
}
}
}
handleConnection(rawConnection: Primus.Spark) {
const fingerprint =
rawConnection.query[config.web.fingerprintOptions.cookieKey];
const { ip, port } = utils.parseHeadersForClientAddress(
rawConnection.headers,
);
this.buildConnection({
rawConnection: rawConnection,
remoteAddress: ip || rawConnection.address.ip,
remotePort: port || rawConnection.address.port,
fingerprint: fingerprint,
});
}
handleDisconnection(rawConnection: Primus.Spark) {
const connections = this.connections();
for (const i in connections) {
if (
connections[i] &&
rawConnection.id === connections[i].rawConnection.id
) {
connections[i].destroy();
break;
}
}
}
async handleData(connection: Connection, data: Record<string, any>) {
const verb = data.event;
delete data.event;
connection.messageId = data.messageId || uuid.v4();
delete data.messageId;
connection.params = {};
if (verb === "action") {
for (const v in data.params) {
connection.params[v] = data.params[v];
}
connection.error = null;
connection.response = {};
return this.processAction(connection);
}
if (verb === "file") {
connection.params = {
file: data.file,
};
return this.processFile(connection);
}
const words = [];
let message;
if (data.room) {
words.push(data.room);
delete data.room;
}
for (const i in data) {
words.push(data[i]);
}
const messageId = connection.messageId;
try {
const data = await connection.verbs(verb, words);
message = { status: "OK", context: "response", data: data };
return this.sendMessage(connection, message, messageId);
} catch (error) {
const formattedError = error.toString();
message = {
status: formattedError,
error: formattedError,
context: "response",
data: data,
};
return this.sendMessage(connection, message, messageId);
}
}
}