add support for websockets api

This commit is contained in:
Dave Horton
2022-02-21 15:01:05 -05:00
parent d01f6dcb33
commit a6731c890d
6 changed files with 1219 additions and 163 deletions
+7
View File
@@ -6,5 +6,12 @@ const initializer = (accountSid, apiKey, opts) => {
initializer.Jambonz = Jambonz;
initializer.WebhookResponse = require('./jambonz/webhook-response');
initializer.WsRouter = require('./jambonz/ws-router');
initializer.WsSession = require('./jambonz/ws-session');
initializer.handleProtocols = (protocols) => {
if (!protocols.has('ws.jambonz.org')) return false;
return 'ws.jambonz.org';
};
module.exports = initializer;
+44
View File
@@ -0,0 +1,44 @@
const assert = require('assert');
const Websocket = require('ws');
const {WebhookResponse} = require('@jambonz/node-client');
module.exports = (ws, {logger, req}) => {
assert(ws instanceof Websocket);
ws.locals = {logger, req, url: req.url, originalUrl: req.url};
/* helper for sending an ack to jambonz */
ws.ack = (msgid, res) => {
let msg = {
type: 'ack',
msgid
};
if (res) msg = {...msg, data: res};
try {
logger.info({msg}, 'sending ack');
ws.send(JSON.stringify(msg));
} catch (err) {
logger.error({err}, 'Error sending ack to jambonz');
}
};
ws.sendCommand = (command, call_sid, payload) => {
payload = payload instanceof WebhookResponse ? payload.toJSON() : payload;
assert.ok(typeof call_sid === 'string', 'invalid or missing call_sid');
assert.ok(typeof payload === 'object' && Object.keys(payload).length > 0,
'invalid or missing payload');
//TODO: validate command
const msg = {
type: 'command',
command,
call_sid,
data: payload
};
try {
logger.info({msg}, 'sending command');
ws.send(JSON.stringify(msg));
} catch (err) {
logger.error({err}, `Error sending command ${command} to jambonz`);
}
};
};
+59
View File
@@ -0,0 +1,59 @@
const assert = require('assert');
const parseurl = require('parseurl');
class WsRouter {
constructor() {
this.routes = [];
}
use(match, callback) {
if (!callback) {
callback = match;
match = '*';
}
assert.ok(typeof callback === 'function' || callback instanceof WsRouter,
'WsRouter.use - callback must be a function or a WsRouter instance');
this.routes.push({match, callback});
}
route(ws) {
const {req} = ws.locals;
const parsed = parseurl(req);
const path = parsed.pathname;
const route = this.routes.find(({match}) => {
/* wildcard */
if ('*' === match) return true;
/* try matching by path */
const urlChunks = path.split('/').filter((c) => c.length);
const matchChunks = match.split('/').filter((c) => c.length);
if (urlChunks.length >= matchChunks.length) {
let idx = 0;
do {
if (urlChunks[idx] !== matchChunks[idx]) break;
idx++;
} while (idx < matchChunks.length);
if (idx > 0) {
req.url = urlChunks.slice(idx).join('/') + '/' + (parsed.search || '');
return true;
}
}
/* TODO: try matching by param */
/* TODO : try matching by query args */
});
if (!route) return false;
const {callback} = route;
if (typeof callback === 'function') {
callback(ws);
return true;
}
return callback.route(ws);
}
}
module.exports = WsRouter;
+81
View File
@@ -0,0 +1,81 @@
const Emitter = require('events');
const assert = require('assert');
const monkeyPatch = require('./monkey-patch-ws');
const noopLogger = {
error: () => {},
info: () => {},
debug: () => {},
child: () => this
};
class WsSession extends Emitter {
constructor({logger, router, ws, req}) {
super();
assert(router);
assert(ws);
assert(req);
this.ws = ws;
this.logger = logger || noopLogger;
this.req = req;
this.router = router;
this._initialMsgRecvd = false;
monkeyPatch(this.ws, {logger, req});
this._setHandlers();
logger.info(`got websocket connection for url ${req.url}`);
}
_setHandlers() {
this.ws
.on('close', this._onClose.bind(this))
.on('message', this._onMessage.bind(this))
.on('error', this._onError.bind(this));
}
_onMessage(data, isBinary) {
if (isBinary) {
this.logger.info('discarding incoming binary message');
return;
}
try {
const {type, msgid, call_sid, hook, data:payload = {}} = JSON.parse(data);
assert.ok(type, 'missing type property');
assert.ok(msgid, 'missing msgid property');
if (!this._initialMsgRecvd) {
this._initialMsgRecvd = true;
if (!this.router.route(this.ws)) {
this.logger.info(`no route found for ${this.req.url}`);
this.ws.removeAllListeners();
this.ws.close();
}
}
this.logger.debug({type, msgid, call_sid, payload}, 'Received message from jambonz');
switch (type) {
case 'session:new':
case 'session:reconnect':
case 'call:status':
case 'verb:hook':
case 'jambonz:error':
this.ws.emit(type, {msgid, hook, payload});
break;
default:
assert.ok(false, `invalid type ${type}`);
}
} catch (err) {
this.logger.info({err}, 'Error handling incoming message');
}
}
_onClose() {
this.emit('close');
}
_onError(err) {
this.emit('error', err);
}
}
module.exports = WsSession;
+1017 -159
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@jambonz/node-client",
"version": "0.2.24",
"version": "0.3.0",
"description": "",
"main": "lib/index.js",
"scripts": {
@@ -15,13 +15,20 @@
},
"license": "MIT",
"dependencies": {
"pino": "^7.8.0",
"ws": "^8.5.0",
"bent": "^7.3.12",
"debug": "^4.3.1",
"parseurl": "^1.3.3",
"debug": "^4.3.1"
},
"devDependencies": {
"tape": "^5.2.0",
"eslint": "^7.20.0",
"eslint-plugin-promise": "^4.3.1",
"nyc": "^15.1.0"
},
"devDependencies": {
"tape": "^5.2.0"
"optionalDependencies": {
"bufferutil": "^4.0.6",
"utf-8-validate": "^5.0.8"
}
}