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; +}