diff --git a/package-lock.json b/package-lock.json
index 238bae03cc..ee4a440e44 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,7 +25,7 @@
"qs": "^6.10.1",
"uuid": "^8.3.2",
"winston": "^3.3.3",
- "ws": "^8.2.0",
+ "ws": "^8.2.1",
"yargs": "^17.1.1"
},
"bin": {
@@ -38,6 +38,7 @@
"@types/primus": "^7.3.5",
"@types/puppeteer": "^5.4.4",
"@types/uuid": "^8.3.1",
+ "@types/ws": "^7.4.7",
"jest": "^27.0.6",
"prettier": "^2.3.2",
"puppeteer": "^10.2.0",
@@ -1101,6 +1102,15 @@
"integrity": "sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==",
"dev": true
},
+ "node_modules/@types/ws": {
+ "version": "7.4.7",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
+ "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/yargs": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.3.tgz",
@@ -6952,6 +6962,15 @@
"integrity": "sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==",
"dev": true
},
+ "@types/ws": {
+ "version": "7.4.7",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
+ "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*"
+ }
+ },
"@types/yargs": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.3.tgz",
diff --git a/package.json b/package.json
index 104aca1cd2..00cefbe141 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,7 @@
"qs": "^6.10.1",
"uuid": "^8.3.2",
"winston": "^3.3.3",
- "ws": "^8.2.0",
+ "ws": "^8.2.1",
"yargs": "^17.1.1"
},
"devDependencies": {
@@ -60,6 +60,7 @@
"@types/primus": "^7.3.5",
"@types/puppeteer": "^5.4.4",
"@types/uuid": "^8.3.1",
+ "@types/ws": "^7.4.7",
"jest": "^27.0.6",
"prettier": "^2.3.2",
"puppeteer": "^10.2.0",
@@ -92,6 +93,7 @@
"debug": "tsc && ts-node-dev --transpile-only --no-deps --inspect -- ./src/server ",
"start": "node ./dist/server.js",
"build": "rm -rf dist && ./node_modules/.bin/tsc --sourceMap false --declaration",
+ "watch": "rm -rf dist && ./node_modules/.bin/tsc --sourceMap false --declaration --watch",
"docs": "typedoc --out docs --theme default src/index.ts",
"lint": "prettier --check src __tests__",
"pretty": "prettier --write src __tests__"
diff --git a/public/chat.html b/public/chat.html
index 8ddee0e41d..f2c392e558 100644
--- a/public/chat.html
+++ b/public/chat.html
@@ -7,10 +7,7 @@
actionhero.js
-
+
diff --git a/public/javascript/.gitkeep b/public/javascript/.gitkeep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/clients/websocket.ts b/src/clients/websocket.ts
index e9b9549d5b..e7d0e4ced6 100644
--- a/src/clients/websocket.ts
+++ b/src/clients/websocket.ts
@@ -1,6 +1,6 @@
export type WebsocketClientState = "disconnected" | "connected" | "connecting";
-export class WebsocketClient {
+export class ActionheroWebsocketClient {
url: string;
options: Record;
id: string;
@@ -9,12 +9,13 @@ export class WebsocketClient {
rooms: string[];
state: WebsocketClientState;
messageId: number;
+ pingTimeout: NodeJS.Timeout;
connection: WebSocket; // built-in type
/**
* Build a new Websocket client to talk to an Actionhero server
*
- * @param url: The URL to connect to + path. `"http://localhost:8080/ws"` would be the localhost default.
+ * @param url: The URL to connect to. `"http://localhost:8080"` would be the localhost default.
* @param options: Options to pass to the websocket connection.
*/
constructor(url: string, options?: { protocols: string }) {
@@ -35,5 +36,40 @@ export class WebsocketClient {
this.state = "connecting";
delete this.connection;
this.connection = new WebSocket(this.url, this.options.protocols);
+
+ this.connection.onopen = () => {
+ this.heartbeat();
+ };
+
+ this.connection.onclose = () => {
+ clearTimeout(this.pingTimeout);
+ };
+
+ this.connection.onerror = (error: any) => {
+ console.error(error);
+ };
+
+ this.connection.onmessage = (message: any) => {
+ let data: Record = message;
+ try {
+ data = JSON.parse(message);
+ } catch {}
+
+ console.log(data);
+ // this.connection.on("ping", this.heartbeat);
+ };
+ }
+
+ private heartbeat() {
+ clearTimeout(this.pingTimeout);
+ this.state = "connected";
+
+ // Use `WebSocket#terminate()`, which immediately destroys the connection,
+ // instead of `WebSocket#close()`, which waits for the close timer.
+ // Delay should be equal to the interval at which your server
+ // sends out pings plus a conservative assumption of the latency.
+ this.pingTimeout = setTimeout(() => {
+ this.connection.close();
+ }, 15 * 1000 * 2);
}
}
diff --git a/src/config/servers/websocket.ts b/src/config/servers/websocket.ts
index 52efcc8deb..16d59a4bfe 100644
--- a/src/config/servers/websocket.ts
+++ b/src/config/servers/websocket.ts
@@ -1,55 +1,10 @@
-// Note that to use the websocket server, you also need the web server enabled
+// Note that to use the websocket server, you also need the `web` server enabled
export const DEFAULT = {
servers: {
- websocket: (config) => {
+ websocket: () => {
return {
enabled: true,
- // you can pass a FQDN (like https://company.com) here or 'window.location.origin'
- // clientUrl: "window.location.origin",
- // Directory to render client-side JS.
- // Path should start with "/" and will be built starting from api.config..general.paths.public
- // clientJsPath: "javascript/",
- // the name of the client-side JS file to render. Both `.js` and `.min.js` versions will be created
- // do not include the file extension
- // set to `undefined` to not render the client-side JS on boot
- // clientJsName: "ActionheroWebsocketClient",
- // should the server signal clients to not reconnect when the server is shutdown/reboot
- destroyClientsOnShutdown: false,
- // what route should the websocket server bind to?
- mount: "/ws",
-
- // websocket Server Options:
- server: {
- // authorization: null,
- // pathname: '/primus',
- // parser: 'JSON',
- // transformer: 'websockets',
- // plugin: {},
- // timeout: 35000,
- // origins: '*',
- // methods: ['GET','HEAD','PUT','POST','DELETE','OPTIONS'],
- // credentials: true,
- // maxAge: '30 days',
- // exposed: false,
- },
-
- // // websocket Client Options:
- // client: {
- // apiPath: "/api", // the api base endpoint on your actionhero server
- // // the cookie name we should use for shared authentication between WS and web connections
- // cookieKey: config.servers.web.fingerprintOptions.cookieKey,
- // // reconnect: {},
- // // timeout: 10000,
- // // ping: 25000,
- // // pong: 10000,
- // // strategy: "online",
- // // manual: false,
- // // websockets: true,
- // // network: true,
- // // transport: {},
- // // queueSize: Infinity,
- // },
};
},
},
diff --git a/src/servers/websocket.ts b/src/servers/websocket.ts
index 53d93a220e..82898c3993 100644
--- a/src/servers/websocket.ts
+++ b/src/servers/websocket.ts
@@ -1,13 +1,13 @@
-// import * as Primus from "primus";
-import * as fs from "fs";
-import * as path from "path";
-import * as util from "util";
+import { IncomingMessage } from "http";
import * as uuid from "uuid";
-import ws from "ws";
-import { api, config, utils, log, Server, Connection } from "../index";
+import * as WebSocket from "ws";
+import { api, config, utils, Server, Connection } from "../index";
-export class WebSocketServer extends Server {
- server: ws.server;
+const pingSleep = 15 * 1000;
+
+export class ActionHeroWebSocketServer extends Server {
+ server: WebSocket.Server;
+ pingTimer: NodeJS.Timeout;
constructor() {
super();
@@ -38,18 +38,15 @@ export class WebSocketServer extends Server {
async start() {
const webserver = api.servers.servers.web;
// this.server = new Primus(webserver.server, this.config.server);
- this.server = new WebSocketServer();
- this.server.mo;
-
- // this.writeClientJS();
+ this.server = new WebSocket.Server({ server: webserver.server });
- this.server.on("connection", (rawConnection) => {
- this.handleConnection(rawConnection);
+ this.server.on("connection", (ws: WebSocket, req: IncomingMessage) => {
+ this.handleConnection(ws, req);
});
- this.server.on("disconnection", (rawConnection) => {
- this.handleDisconnection(rawConnection);
- });
+ // this.server.on("disconnection", (rawConnection: WebSocket) => {
+ // this.handleDisconnection(rawConnection);
+ // });
this.log(
`webSockets bound to ${webserver.options.bindIP}: ${webserver.options.port}`,
@@ -57,9 +54,19 @@ export class WebSocketServer extends Server {
);
this.on("connection", (connection: Connection) => {
- connection.rawConnection.on("data", (data) => {
- this.handleData(connection, data);
+ connection.rawConnection.ws.on("message", (message: string) => {
+ try {
+ const data = JSON.parse(message);
+ this.handleData(connection, data);
+ } catch (error) {
+ this.log(`cannot parse client message`, "error", message);
+ }
});
+
+ connection.rawConnection.ws.on("close", () => connection.destroy());
+
+ connection.rawConnection.ws.isAlive = true;
+ connection.rawConnection.ws.on("pong", heartbeat);
});
this.on("actionComplete", (data) => {
@@ -68,38 +75,47 @@ export class WebSocketServer extends Server {
this.sendMessage(data.connection, data.response, data.messageId);
}
});
+
+ this.pingTimer = setInterval(() => {
+ this.connections().forEach((connection: Connection) => {
+ if (connection.rawConnection.ws.isAlive === false) {
+ return connection.rawConnection.ws.terminate();
+ }
+
+ connection.rawConnection.ws.isAlive = false;
+ connection.rawConnection.ws.ping(noop);
+ });
+ }, pingSleep);
}
async stop() {
if (!this.server) return;
+ clearInterval(this.pingTimer);
if (this.config.destroyClientsOnShutdown === true) {
this.connections().forEach((connection: Connection) => {
connection.destroy();
});
}
-
- //@ts-ignore
- this.server.stop();
}
- async sendMessage(connection: Connection, message, messageId: string) {
+ async sendMessage(
+ connection: Connection,
+ message: Record,
+ 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) {
+ if (!message.context) message.context = "response";
+ if (!messageId) messageId = connection.messageId;
+ if (message.context === "response" && !message.messageId)
message.messageId = messageId;
- }
- connection.rawConnection.write(message);
+
+ connection.rawConnection.ws.send(JSON.stringify(message));
}
async sendFile(
@@ -122,9 +138,7 @@ export class WebSocketServer extends Server {
try {
if (!error) {
- fileStream.on("data", (d) => {
- content += d;
- });
+ fileStream.on("data", (d) => (content += d));
fileStream.on("end", () => {
response.content = content;
this.sendMessage(connection, response, messageId);
@@ -138,115 +152,24 @@ export class WebSocketServer extends Server {
}
}
- //@ts-ignore
- goodbye(connection: Connection) {
- connection.rawConnection.end();
+ async goodbye(connection: Connection) {
+ connection.rawConnection.ws.terminate();
}
- // 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);
- // }
- // 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) {
- const fingerprint =
- rawConnection.query[config.servers.web.fingerprintOptions.cookieKey];
- const { ip, port } = utils.parseHeadersForClientAddress(
- rawConnection.headers
- );
+ handleConnection(ws: WebSocket, req: IncomingMessage) {
+ const webserver = api.servers.servers.web;
+ const { fingerprint } = webserver["fingerPrinter"].fingerprint(req);
+ const { ip, port } = utils.parseHeadersForClientAddress(req.headers);
this.buildConnection({
- rawConnection: rawConnection,
- remoteAddress: ip || rawConnection.address.ip,
- remotePort: port || rawConnection.address.port,
+ rawConnection: { ws, req },
+ remoteAddress: ip || req.connection.remoteAddress || "0.0.0.0",
+ remotePort: port || req.connection.remotePort || "0",
fingerprint: fingerprint,
});
}
- handleDisconnection(rawConnection) {
- 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, data) {
+ async handleData(connection: Connection, data: Record) {
const verb = data.event;
delete data.event;
@@ -259,7 +182,7 @@ export class WebSocketServer extends Server {
connection.params[v] = data.params[v];
}
connection.error = null;
- connection.response = {};
+ connection["response"] = {};
return this.processAction(connection);
}
@@ -296,3 +219,9 @@ export class WebSocketServer extends Server {
}
}
}
+
+function noop() {}
+
+function heartbeat() {
+ this.isAlive = true;
+}