diff --git a/client/ActionheroWebsocketClient.js b/client/ActionheroWebsocketClient.js deleted file mode 100644 index ee482c671..000000000 --- a/client/ActionheroWebsocketClient.js +++ /dev/null @@ -1,277 +0,0 @@ -var ActionheroWebsocketClient = function (options, client) { - var self = this - - self.callbacks = {} - self.id = null - self.events = {} - self.rooms = [] - self.state = 'disconnected' - - self.options = self.defaults() - for (var i in options) { - self.options[i] = options[i] - } - - if (client) { - self.externalClient = true - self.client = client - } -} - -if (typeof Primus === 'undefined') { - var util = require('util') - var EventEmitter = require('events').EventEmitter - util.inherits(ActionheroWebsocketClient, EventEmitter) -} else { - ActionheroWebsocketClient.prototype = new Primus.EventEmitter() -} - -ActionheroWebsocketClient.prototype.defaults = function () { - %%DEFAULTS%% -} - -// ////////////// -// CONNECTION // -// ////////////// - -ActionheroWebsocketClient.prototype.connect = function (callback) { - var self = this - self.messageId = self.messageId || 0 - - if (self.client && self.externalClient !== true) { - self.client.end() - self.client.removeAllListeners() - delete self.client - self.client = Primus.connect(self.urlWithSession(), self.options) - } else if (self.client && self.externalClient === true) { - self.client.end() - self.client.open() - } else { - self.client = Primus.connect(self.urlWithSession(), self.options) - } - - self.client.once('open', function () { - self.configure(function (details) { - self.state = 'connected' - self.emit('connected') - if (typeof callback === 'function') { callback(null, details) } - }) - }) - - self.client.on('error', function (error) { - self.emit('error', error) - }) - - self.client.on('reconnect', function () { - self.state = 'connected' - self.emit('reconnect') - }) - - self.client.on('reconnecting', function () { - self.emit('reconnecting') - self.state = 'reconnecting' - self.emit('disconnected') - }) - - self.client.on('timeout', function () { - self.state = 'timeout' - self.emit('timeout') - }) - - self.client.on('close', function () { - if (self.state !== 'disconnected') { - self.state = 'disconnected' - self.emit('disconnected') - } - }) - - self.client.on('end', function () { - if (self.state !== 'disconnected') { - self.state = 'disconnected' - self.emit('disconnected') - } - }) - - self.client.on('data', function (data) { - self.handleMessage(data) - }) -} - -ActionheroWebsocketClient.prototype.urlWithSession = function () { - var self = this - var url = self.options.url - if (self.options.cookieKey && self.options.cookieKey.length > 0) { - var cookieValue = self.getCookie(self.options.cookieKey) - if (cookieValue && cookieValue.length > 0 ) { url += '?' + self.options.cookieKey + '=' + cookieValue } - } - - return url -} - -ActionheroWebsocketClient.prototype.getCookie = function (name) { - if (typeof document === 'undefined' || !document.cookie) { return } - var match = document.cookie.match(new RegExp(name + '=([^;]+)')) - if (match) return match[1] -} - -ActionheroWebsocketClient.prototype.configure = function (callback) { - var self = this - - self.rooms.forEach(function (room) { - self.send({event: 'roomAdd', room: room}) - }) - - self.detailsView(function (details) { - self.id = details.data.id - self.fingerprint = details.data.fingerprint - self.rooms = details.data.rooms - return callback(details) - }) -} - -// ///////////// -// MESSAGING // -// ///////////// - -ActionheroWebsocketClient.prototype.send = function (args, callback) { - // primus will buffer messages when not connected - var self = this - self.messageId++ - args.messageId = args.params - ? (args.params.messageId || args.messageId || self.messageId ) - : ( args.messageId || self.messageId ) - if (typeof callback === 'function') { self.callbacks[args.messageId] = callback } - self.client.write(args) -} - -ActionheroWebsocketClient.prototype.handleMessage = function (message) { - var self = this - self.emit('message', message) - var messageId = message.messageId - - if (message.context === 'response') { - if (typeof self.callbacks[messageId] === 'function') { - self.callbacks[messageId](message) - } - delete self.callbacks[messageId] - } else if (message.context === 'user') { - self.emit('say', message) - } else if (message.context === 'alert') { - self.emit('alert', message) - } else if (message.welcome && message.context === 'api') { - self.welcomeMessage = message.welcome - self.emit('welcome', message) - } else if (message.context === 'api') { - self.emit('api', message) - } -} - -// /////////// -// ACTIONS // -// /////////// - -ActionheroWebsocketClient.prototype.action = function (action, params, callback) { - if (!callback && typeof params === 'function') { - callback = params - params = null - } - if (!params) { params = {} } - params.action = action - - if (this.state !== 'connected') { - this.actionWeb(params, callback) - } else { - this.actionWebSocket(params, callback) - } -} - -ActionheroWebsocketClient.prototype.actionWeb = function (params, callback) { - var xmlhttp = new XMLHttpRequest() - xmlhttp.onreadystatechange = function () { - var response - if (xmlhttp.readyState === 4) { - if (xmlhttp.status === 200) { - response = JSON.parse(xmlhttp.responseText) - } else { - try { - response = JSON.parse(xmlhttp.responseText) - } catch (e) { - response = { error: {statusText: xmlhttp.statusText, responseText: xmlhttp.responseText} } - } - } - callback(response) - } - } - - var method = (params.httpMethod || 'POST').toUpperCase() - var url = this.options.url + this.options.apiPath + '?action=' + params.action - - if (method === 'GET') { - for (var param in params) { - if (~['action', 'httpMethod'].indexOf(param)) continue - url += '&' + param + '=' + params[param] - } - } - - xmlhttp.open(method, url, true) - xmlhttp.setRequestHeader('Content-Type', 'application/json') - xmlhttp.send(JSON.stringify(params)) -} - -ActionheroWebsocketClient.prototype.actionWebSocket = function (params, callback) { - this.send({event: 'action', params: params}, callback) -} - -// //////////// -// COMMANDS // -// //////////// - -ActionheroWebsocketClient.prototype.say = function (room, message, callback) { - this.send({event: 'say', room: room, message: message}, callback) -} - -ActionheroWebsocketClient.prototype.file = function (file, callback) { - this.send({event: 'file', file: file}, callback) -} - -ActionheroWebsocketClient.prototype.detailsView = function (callback) { - this.send({event: 'detailsView'}, callback) -} - -ActionheroWebsocketClient.prototype.roomView = function (room, callback) { - this.send({event: 'roomView', room: room}, callback) -} - -ActionheroWebsocketClient.prototype.roomAdd = function (room, callback) { - var self = this - self.send({event: 'roomAdd', room: room}, function (data) { - self.configure(function () { - if (typeof callback === 'function') { callback(data) } - }) - }) -} - -ActionheroWebsocketClient.prototype.roomLeave = function (room, callback) { - var self = this - var index = self.rooms.indexOf(room) - if (index > -1) { self.rooms.splice(index, 1) } - this.send({event: 'roomLeave', room: room}, function (data) { - self.configure(function () { - if (typeof callback === 'function') { callback(data) } - }) - }) -} - -ActionheroWebsocketClient.prototype.documentation = function (callback) { - this.send({event: 'documentation'}, callback) -} - -ActionheroWebsocketClient.prototype.disconnect = function () { - this.state = 'disconnected' - this.client.end() - this.emit('disconnected') -} - -// depreciated lowercase name -var ActionheroWebsocketClient = ActionheroWebsocketClient; -ActionheroWebsocketClient; diff --git a/clients.tsconfig.json b/clients.tsconfig.json new file mode 100644 index 000000000..99ecc6596 --- /dev/null +++ b/clients.tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "outDir": "./public/clients", + "allowJs": true, + "target": "es2020", + "lib": ["es2020", "DOM"], + "module": "ES2020", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "types": [] + }, + "include": ["./src/clients/**/*"] +} diff --git a/package-lock.json b/package-lock.json index af4d09042..d69401739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,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": { @@ -37,6 +37,7 @@ "@types/primus": "^7.3.5", "@types/puppeteer": "^5.4.4", "@types/uuid": "^8.3.1", + "@types/ws": "^7.4.7", "ioredis-mock": "^5.6.0", "jest": "^27.0.6", "prettier": "^2.3.2", @@ -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.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -6980,6 +6990,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.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", diff --git a/package.json b/package.json index 701197a8d..050c29d3f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,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": { @@ -59,6 +59,7 @@ "@types/primus": "^7.3.5", "@types/puppeteer": "^5.4.4", "@types/uuid": "^8.3.1", + "@types/ws": "^7.4.7", "ioredis-mock": "^5.6.0", "jest": "^27.0.6", "prettier": "^2.3.2", @@ -86,12 +87,14 @@ "scripts": { "postinstall": "echo 'To generate a new actionhero project, run \"npx actionhero generate\"'", "test": "jest", - "prepare": "npm run build && npm run docs", + "prepare": "npm run build && npm run build-clients && npm run docs", "pretest": "npm run lint && npm run build", "dev": "ts-node-dev --transpile-only --no-deps ./src/server", "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", + "build-clients": "rm -rf public/clients && ./node_modules/.bin/tsc --declaration --project clients.tsconfig.json", + "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 8ddee0e41..46497b3e1 100644 --- a/public/chat.html +++ b/public/chat.html @@ -6,14 +6,9 @@ actionhero.js - - - +
@@ -60,81 +55,52 @@

- + diff --git a/public/clients/websocket.d.ts b/public/clients/websocket.d.ts new file mode 100644 index 000000000..f8df3ad9e --- /dev/null +++ b/public/clients/websocket.d.ts @@ -0,0 +1,101 @@ +export declare type WebsocketClientState = "disconnected" | "connected" | "connecting" | "reconnecting"; +export declare type WebsocketResponse> = { + context: string; + messageId: string | number; + status: string; + data: DataType; +}; +export declare class ActionheroWebsocketClient { + url: string; + options: Record; + id: string; + fingerprint: string; + callbacks: Record void>; + events: Record {}>; + rooms: string[]; + state: WebsocketClientState; + messageId: number; + pingTimeout: ReturnType; + connection: WebSocket; + onConnect: (state: WebsocketClientState) => void; + onDisconnect: (state: WebsocketClientState) => void; + onMessage: (response: WebsocketResponse<{ + message: string; + }>) => void; + onSay: (response: WebsocketResponse<{ + message: string; + }>) => void; + onWelcome: (response: WebsocketResponse<{ + welcome: string; + }>) => void; + /** + * Build a new Websocket client to talk to an Actionhero server + * + * @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?: { + cookieKey: string; + protocols: string; + apiPath: string; + }); + connect(): Promise>; + send(args: Record): Promise>; + say(room: string, message: string | Record): Promise>; + file(file: string): Promise>; + detailsView(): Promise>; + roomView(room: string): Promise>; + roomAdd(room: string): Promise>; + roomLeave(room: string): Promise>; + documentation(): Promise>; + disconnect(): void; + action(actionName: string, params: Record): Promise>; + actionWeb(params: Record): Promise; + actionWebSocket(params: Record): Promise>; + configure(): Promise>; + private urlWithSession; + private getCookie; + private heartbeat; +} diff --git a/public/clients/websocket.js b/public/clients/websocket.js new file mode 100644 index 000000000..321579df7 --- /dev/null +++ b/public/clients/websocket.js @@ -0,0 +1,221 @@ +export class ActionheroWebsocketClient { + /** + * Build a new Websocket client to talk to an Actionhero server + * + * @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, options = { + cookieKey: "", + protocols: "", + apiPath: "/api", + }) { + this.url = url; + this.options = options; + this.callbacks = {}; + this.id = null; + this.fingerprint = null; + this.events = {}; + this.rooms = []; + this.state = "disconnected"; + this.messageId = 0; + } + ////////////// + // COMMANDS // + ////////////// + async connect() { + if (this.state === "connected") + return; + if (this.state === "connecting") + return; + this.state = "connecting"; + delete this.connection; + delete this.id; + delete this.fingerprint; + await new Promise((resolve) => { + this.connection = new WebSocket(this.urlWithSession(), this.options.protocols && this.options.protocols.length > 0 + ? this.options.protocols + : undefined); + this.connection.onopen = () => { + this.heartbeat(); + if (typeof this.onConnect === "function") + this.onConnect(this.state); + resolve(null); + }; + this.connection.onclose = () => { + clearTimeout(this.pingTimeout); + // if (this.state !== "disconnected") this.reconnect(); + if (typeof this.onDisconnect === "function") { + this.onDisconnect(this.state); + } + }; + this.connection.onerror = (error) => { + console.error(error); + }; + this.connection.onmessage = (message) => { + if (!message.data) + return; + const data = JSON.parse(message.data); + if (typeof this.onMessage === "function") { + this.onMessage(data); + } + if (data.context === "response") { + if (data.messageId && this.callbacks[this.messageId]) { + this.callbacks[this.messageId](data); + delete this.callbacks[this.messageId]; + } + } + else if (data.context === "user" && + typeof this.onSay === "function") { + this.onSay(data); + } + else if (data["welcome"] && + data.context === "api" && + typeof this.onWelcome === "function") { + this.onWelcome(data); + } + }; + }); + return this.configure(); + } + send(args) { + this.messageId++; + args.messageId = args.params + ? args.params.messageId || args.messageId || this.messageId + : args.messageId || this.messageId; + this.connection.send(JSON.stringify(args)); + return new Promise((resolve) => { + this.callbacks[this.messageId] = resolve; + }); + } + async say(room, message) { + return this.send({ event: "say", room: room, message: message }); + } + async file(file) { + return this.send({ event: "file", file }); + } + async detailsView() { + return this.send({ event: "detailsView" }); + } + async roomView(room) { + return this.send({ event: "roomView", room: room }); + } + async roomAdd(room) { + await this.send({ event: "roomAdd", room: room }); + return this.configure(); + } + async roomLeave(room) { + await this.send({ event: "roomLeave", room: room }); + return this.configure(); + } + async documentation() { + return this.send({ event: "documentation" }); + } + disconnect() { + this.state = "disconnected"; + this.connection.close(); + } + ///////////// + // ACTIONS // + ///////////// + async action(actionName, params) { + if (!params) + params = {}; + params.action = actionName; + let response; + if (this.state !== "connected") { + response = await this.actionWeb(params); + } + else { + response = await this.actionWebSocket(params); + } + if (response.error) + throw new Error(response.error); + return response; + } + async actionWeb(params) { + return new Promise((resolve, reject) => { + const req = new XMLHttpRequest(); + req.onreadystatechange = () => { + var response; + if (req.readyState === 4) { + if (req.status === 200) { + response = JSON.parse(req.responseText); + } + else { + try { + response = JSON.parse(req.responseText); + } + catch (e) { + response = { + error: { + statusText: req.statusText, + responseText: req.responseText, + }, + }; + } + } + return resolve(response); + } + }; + // TODO: handle rejection cases + const method = (params.httpMethod || "POST").toUpperCase(); + let url = this.url + this.options.apiPath + "?action=" + params.action; + if (method === "GET") { + for (var param in params) { + if (~["action", "httpMethod"].indexOf(param)) + continue; + url += "&" + param + "=" + params[param]; + } + } + req.open(method, url, true); + req.setRequestHeader("Content-Type", "application/json"); + req.send(JSON.stringify(params)); + }); + } + async actionWebSocket(params) { + return this.send({ event: "action", params: params }); + } + ///////////// + // private // + ///////////// + async configure() { + for (const room of this.rooms) + await this.send({ event: "roomAdd", room }); + const details = await this.detailsView(); + this.id = details.data.id; + this.fingerprint = details.data.fingerprint; + this.rooms = details.data.rooms; + return details; + } + urlWithSession() { + let url = this.url; + if (this.options.cookieKey) { + const cookieValue = this.getCookie(this.options.cookieKey); + if (cookieValue && cookieValue.length > 0) { + url += "?" + this.options.cookieKey + "=" + cookieValue; + } + } + return url; + } + getCookie(name) { + if (typeof document === "undefined" || !document.cookie) { + return; + } + var match = document.cookie.match(new RegExp(name + "=([^;]+)")); + if (match) + return match[1]; + } + 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); + } +} +//# sourceMappingURL=websocket.js.map \ No newline at end of file diff --git a/public/clients/websocket.js.map b/public/clients/websocket.js.map new file mode 100644 index 000000000..73566d2ad --- /dev/null +++ b/public/clients/websocket.js.map @@ -0,0 +1 @@ +{"version":3,"file":"websocket.js","sourceRoot":"","sources":["../../src/clients/websocket.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/public/javascript/.gitkeep b/public/javascript/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/classes/action.ts b/src/classes/action.ts index 4d0f0f592..63d327ac2 100644 --- a/src/classes/action.ts +++ b/src/classes/action.ts @@ -1,5 +1,6 @@ import { api } from "../index"; import type { LogLevels } from "../modules/log"; +import { ConnectionVerb } from "./connection"; import { Inputs } from "./inputs"; /** @@ -89,7 +90,7 @@ export abstract class Action { } if ( api.connections && - api.connections.allowedVerbs.indexOf(this.name) >= 0 + api.connections.allowedVerbs.indexOf(this.name as ConnectionVerb) >= 0 ) { throw new Error( `action \`${this.name}\` is a reserved verb for connections. choose a new name` diff --git a/src/classes/connection.ts b/src/classes/connection.ts index b846b7771..c9c0d34f8 100644 --- a/src/classes/connection.ts +++ b/src/classes/connection.ts @@ -2,6 +2,23 @@ import * as uuid from "uuid"; import { api, chatRoom } from "./../index"; import { config } from "./../modules/config"; +export const ConnectionVerbs = [ + "quit", + "exit", + "paramAdd", + "paramDelete", + "paramView", + "paramsView", + "paramsDelete", + "roomAdd", + "roomLeave", + "roomView", + "detailsView", + "documentation", + "say", +] as const; +export type ConnectionVerb = typeof ConnectionVerbs[number]; + /** * The generic representation of a connection for all server types is an Actionhero.Connection. You will never be creating these yourself via an action or task, but you will find them in your Actions and Action Middleware. */ @@ -153,7 +170,10 @@ export class Connection { /** * Send a message to a connection. Uses Server#sendMessage. */ - async sendMessage(message: string | object | Array, verb?: string) { + async sendMessage( + message: string | object | Array, + verb?: ConnectionVerb + ) { throw new Error("not implemented"); } @@ -199,14 +219,10 @@ export class Connection { delete api.connections.connections[this.id]; } - private set(key, value) { - this[key] = value; - } - /** * Try to run a verb command for a connection */ - private async verbs(verb: string, words: Array) { + async verbs(verb: ConnectionVerb, words: Array) { let key: string; let value: string; let room: string; @@ -299,6 +315,10 @@ export class Connection { throw error; } } + + private set(key: string, value: any) { + this[key] = value; + } } export interface ConnectionParams { diff --git a/src/classes/server.ts b/src/classes/server.ts index a914d8664..d60239b1c 100644 --- a/src/classes/server.ts +++ b/src/classes/server.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "events"; import { api, config } from "../index"; import { log, LogLevels } from "../modules/log"; import { ActionProcessor } from "./actionProcessor"; -import { Connection } from "./connection"; +import { Connection, ConnectionVerb } from "./connection"; interface ServerConfig { [key: string]: any; @@ -15,7 +15,7 @@ export abstract class Server extends EventEmitter { /**The name & type of the server. */ type: string; /**What connection verbs can connections of this type use? */ - verbs?: Array; + verbs?: ConnectionVerb[]; /**Shorthand for `api.config.servers[this.type]` */ config?: ServerConfig; options?: { @@ -23,6 +23,7 @@ export abstract class Server extends EventEmitter { }; /** attributes of the server */ attributes: { + verbs?: ConnectionVerb[]; [key: string]: any; }; /**Can connections of this server use the chat system? */ diff --git a/src/clients/websocket.ts b/src/clients/websocket.ts new file mode 100644 index 000000000..f1e961a50 --- /dev/null +++ b/src/clients/websocket.ts @@ -0,0 +1,308 @@ +export type WebsocketClientState = + | "disconnected" + | "connected" + | "connecting" + | "reconnecting"; + +export type WebsocketResponse> = { + context: string; + messageId: string | number; + status: string; + data: DataType; +}; + +export class ActionheroWebsocketClient { + url: string; + options: Record; + id: string; + fingerprint: string; + callbacks: Record void>; + events: Record {}>; + rooms: string[]; + state: WebsocketClientState; + messageId: number; + pingTimeout: ReturnType; + connection: WebSocket; // built-in type + + // optional subscription methods + onConnect: (state: WebsocketClientState) => void; + onDisconnect: (state: WebsocketClientState) => void; + onMessage: (response: WebsocketResponse<{ message: string }>) => void; + onSay: (response: WebsocketResponse<{ message: string }>) => void; + onWelcome: (response: WebsocketResponse<{ welcome: string }>) => void; + + /** + * Build a new Websocket client to talk to an Actionhero server + * + * @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 = { + cookieKey: "", + protocols: "", + apiPath: "/api", + } + ) { + this.url = url; + this.options = options; + this.callbacks = {}; + this.id = null; + this.fingerprint = null; + this.events = {}; + this.rooms = []; + this.state = "disconnected"; + this.messageId = 0; + } + + ////////////// + // COMMANDS // + ////////////// + + async connect() { + if (this.state === "connected") return; + if (this.state === "connecting") return; + + this.state = "connecting"; + delete this.connection; + delete this.id; + delete this.fingerprint; + + await new Promise((resolve) => { + this.connection = new WebSocket( + this.urlWithSession(), + this.options.protocols && this.options.protocols.length > 0 + ? this.options.protocols + : undefined + ); + + this.connection.onopen = () => { + this.heartbeat(); + if (typeof this.onConnect === "function") this.onConnect(this.state); + resolve(null); + }; + + this.connection.onclose = () => { + clearTimeout(this.pingTimeout); + // if (this.state !== "disconnected") this.reconnect(); + if (typeof this.onDisconnect === "function") { + this.onDisconnect(this.state); + } + }; + + this.connection.onerror = (error: any) => { + console.error(error); + }; + + this.connection.onmessage = (message: MessageEvent) => { + if (!message.data) return; + + const data: WebsocketResponse = JSON.parse(message.data); + + if (typeof this.onMessage === "function") { + this.onMessage(data); + } + + if (data.context === "response") { + if (data.messageId && this.callbacks[this.messageId]) { + this.callbacks[this.messageId](data); + delete this.callbacks[this.messageId]; + } + } else if ( + data.context === "user" && + typeof this.onSay === "function" + ) { + this.onSay(data); + } else if ( + data["welcome"] && + data.context === "api" && + typeof this.onWelcome === "function" + ) { + this.onWelcome(data); + } + }; + }); + + return this.configure(); + } + + send( + args: Record + ): Promise> { + this.messageId++; + args.messageId = args.params + ? args.params.messageId || args.messageId || this.messageId + : args.messageId || this.messageId; + + this.connection.send(JSON.stringify(args)); + + return new Promise((resolve) => { + this.callbacks[this.messageId] = resolve; + }); + } + + async say(room: string, message: string | Record) { + return this.send({ event: "say", room: room, message: message }); + } + + async file(file: string) { + return this.send({ event: "file", file }); + } + + async detailsView() { + return this.send<{ + connectedAt: number; + fingerprint: string; + id: string; + remoteIp: string; + remotePort: string; + rooms: string[]; + totalActions: number; + }>({ event: "detailsView" }); + } + + async roomView(room: string) { + return this.send<{ rooms: string[] }>({ event: "roomView", room: room }); + } + + async roomAdd(room: string) { + await this.send({ event: "roomAdd", room: room }); + return this.configure(); + } + + async roomLeave(room: string) { + await this.send({ event: "roomLeave", room: room }); + return this.configure(); + } + + async documentation() { + return this.send({ event: "documentation" }); + } + + disconnect() { + this.state = "disconnected"; + this.connection.close(); + } + + ///////////// + // ACTIONS // + ///////////// + + async action(actionName: string, params: Record) { + if (!params) params = {}; + params.action = actionName; + + let response: Record; + if (this.state !== "connected") { + response = await this.actionWeb(params); + } else { + response = await this.actionWebSocket(params); + } + + if (response.error) throw new Error(response.error); + + return response; + } + + async actionWeb(params: Record) { + return new Promise((resolve, reject) => { + const req = new XMLHttpRequest(); + + req.onreadystatechange = () => { + var response; + if (req.readyState === 4) { + if (req.status === 200) { + response = JSON.parse(req.responseText); + } else { + try { + response = JSON.parse(req.responseText); + } catch (e) { + response = { + error: { + statusText: req.statusText, + responseText: req.responseText, + }, + }; + } + } + return resolve(response); + } + }; + + // TODO: handle rejection cases + + const method = (params.httpMethod || "POST").toUpperCase(); + let url = this.url + this.options.apiPath + "?action=" + params.action; + + if (method === "GET") { + for (var param in params) { + if (~["action", "httpMethod"].indexOf(param)) continue; + url += "&" + param + "=" + params[param]; + } + } + + req.open(method, url, true); + req.setRequestHeader("Content-Type", "application/json"); + req.send(JSON.stringify(params)); + }); + } + + async actionWebSocket(params: Record) { + return this.send({ event: "action", params: params }); + } + + ///////////// + // private // + ///////////// + + async configure() { + for (const room of this.rooms) await this.send({ event: "roomAdd", room }); + const details = await this.detailsView(); + this.id = details.data.id; + this.fingerprint = details.data.fingerprint; + this.rooms = details.data.rooms; + + return details; + } + + private urlWithSession() { + let url = this.url; + if (this.options.cookieKey) { + const cookieValue = this.getCookie(this.options.cookieKey); + if (cookieValue && cookieValue.length > 0) { + url += "?" + this.options.cookieKey + "=" + cookieValue; + } + } + + return url; + } + + private getCookie(name) { + if (typeof document === "undefined" || !document.cookie) { + return; + } + var match = document.cookie.match(new RegExp(name + "=([^;]+)")); + if (match) return match[1]; + } + + 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); + } + + // private reconnect() { + // clearTimeout(this.pingTimeout); + // this.connection.close(); + // this.state = "reconnecting"; + // this.pingTimeout = setTimeout(() => this.connect, 1000 * 5); + // } +} diff --git a/src/config/servers/websocket.ts b/src/config/servers/websocket.ts index 0c8208483..16d59a4bf 100644 --- a/src/config/servers/websocket.ts +++ b/src/config/servers/websocket.ts @@ -1,53 +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, - - // 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/index.ts b/src/index.ts index 351aa08e3..0362292cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,11 @@ export { Api } from "./classes/api"; export { Process } from "./classes/process"; export { Initializer } from "./classes/initializer"; -export { Connection } from "./classes/connection"; +export { + Connection, + ConnectionVerb, + ConnectionVerbs, +} from "./classes/connection"; export { ExceptionReporter } from "./classes/exceptionReporter"; export { Action } from "./classes/action"; export { Task } from "./classes/task"; diff --git a/src/initializers/connections.ts b/src/initializers/connections.ts index 148b1ad1f..0ad3da2d6 100644 --- a/src/initializers/connections.ts +++ b/src/initializers/connections.ts @@ -1,4 +1,11 @@ -import { api, redis, Initializer, Connection } from "../index"; +import { + api, + redis, + Initializer, + Connection, + ConnectionVerbs, + ConnectionVerb, +} from "../index"; /** * ```js @@ -33,7 +40,7 @@ export interface ConnectionsApi { [key: string]: ConnectionMiddleware; }; globalMiddleware: Array; - allowedVerbs: Array; + allowedVerbs: ConnectionVerb[]; cleanConnection: Function; apply: ( connectionId: string, @@ -55,22 +62,7 @@ export class Connections extends Initializer { connections: {}, middleware: {}, globalMiddleware: [], - - allowedVerbs: [ - "quit", - "exit", - "documentation", - "paramAdd", - "paramDelete", - "paramView", - "paramsView", - "paramsDelete", - "roomAdd", - "roomLeave", - "roomView", - "detailsView", - "say", - ], + allowedVerbs: [...ConnectionVerbs], /** * Find a connection on any server in the cluster and call a method on it. diff --git a/src/initializers/specHelper.ts b/src/initializers/specHelper.ts index 5de0f6c6a..143d803b4 100644 --- a/src/initializers/specHelper.ts +++ b/src/initializers/specHelper.ts @@ -1,4 +1,5 @@ import * as uuid from "uuid"; +import { ConnectionVerbs } from "../classes/connection"; import { api, log, env, Initializer, Server, Connection } from "../index"; export interface SpecHelperApi { @@ -41,7 +42,7 @@ export class SpecHelper extends Initializer { logConnections: false, logExits: false, sendWelcomeMessage: true, - verbs: api.connections.allowedVerbs, + verbs: [...ConnectionVerbs], }; } diff --git a/src/servers/web.ts b/src/servers/web.ts index 9b72ee780..816dd2440 100644 --- a/src/servers/web.ts +++ b/src/servers/web.ts @@ -392,7 +392,7 @@ export class WebServer extends Server { } } - handleRequest(req, res) { + handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { const { fingerprint, headersHash } = this.fingerPrinter.fingerprint(req); const responseHeaders = []; const cookies = utils.parseCookies(req); diff --git a/src/servers/websocket.ts b/src/servers/websocket.ts index 248fca7ce..2b38a8436 100644 --- a/src/servers/websocket.ts +++ b/src/servers/websocket.ts @@ -1,12 +1,15 @@ -import * as Primus from "primus"; +import { IncomingMessage } from "http"; 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"; +import * as WebSocket from "ws"; +import { api, config, utils, Server, Connection } from "../index"; -export class WebSocketServer extends Server { - server: Primus; +const pingSleep = 15 * 1000; + +export class ActionHeroWebSocketServer extends Server { + server: WebSocket.Server; + pingTimer: NodeJS.Timeout; constructor() { super(); @@ -36,16 +39,10 @@ export class WebSocketServer extends Server { async start() { const webserver = api.servers.servers.web; - this.server = new Primus(webserver.server, this.config.server); - - this.writeClientJS(); - - this.server.on("connection", (rawConnection) => { - this.handleConnection(rawConnection); - }); + this.server = new WebSocket.Server({ server: webserver.server }); - this.server.on("disconnection", (rawConnection) => { - this.handleDisconnection(rawConnection); + this.server.on("connection", (ws: WebSocket, req: IncomingMessage) => { + this.handleConnection(ws, req); }); this.log( @@ -54,9 +51,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) => { @@ -65,38 +72,49 @@ 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); + + this.copyClientLib(); } 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.destroy(); } - 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( @@ -119,9 +137,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); @@ -135,115 +151,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; @@ -256,7 +181,7 @@ export class WebSocketServer extends Server { connection.params[v] = data.params[v]; } connection.error = null; - connection.response = {}; + connection["response"] = {}; return this.processAction(connection); } @@ -292,4 +217,21 @@ export class WebSocketServer extends Server { return this.sendMessage(connection, message, messageId); } } + + copyClientLib() { + const files = ["websocket.d.ts", "websocket.js", "websocket.js.map"]; + const src = path.join(__dirname, "..", "..", "public", "clients"); + for (const p of config.general.paths.public) { + fs.mkdirSync(path.join(p, "clients"), { recursive: true }); + for (const f of files) { + fs.copyFileSync(path.join(src, f), path.join(p, "clients", f)); + } + } + } +} + +function noop() {} + +function heartbeat() { + this.isAlive = true; }