UNPKG

teckos

Version:

uWebsocket.js based server component supporting async callbacks

637 lines (522 loc) 18.5 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var Redis = require('ioredis'); var crypto = require('crypto'); var events = require('events'); var node_module = require('node:module'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n["default"] = e; return Object.freeze(n); } var Redis__default = /*#__PURE__*/_interopDefaultLegacy(Redis); var crypto__namespace = /*#__PURE__*/_interopNamespace(crypto); /** * Adapted from React: https://github.com/facebook/react/blob/master/packages/shared/formatProdErrorMessage.js * * Do not require this module directly! Use normal throw error calls. These messages will be replaced with error codes * during build. * @param {number} code */ function formatProdErrorMessage(code) { return `Minified Redux error #${code}; visit https://redux.js.org/Errors?code=${code} for the full message or ` + 'use the non-minified dev environment for full errors. '; } // eslint-disable-next-line import/no-default-export class SocketEventEmitter extends events.EventEmitter { _maxListeners = 10; _handlers = {}; _globalHandlers = []; addListener = (event, listener) => { if (Object.keys(this._handlers).length === this._maxListeners) { throw new Error(process.env.NODE_ENV === "production" ? formatProdErrorMessage(1) : 'Max listeners reached'); } this._handlers[event] = this._handlers[event] || []; this._handlers[event].push(listener); return this; }; addGlobalListener = listener => { this._globalHandlers.push(listener); return this; }; removeGlobalListener = listener => { this._globalHandlers = this._globalHandlers.filter(curr => curr === listener); return this; }; once = (event, listener) => { if (Object.keys(this._handlers).length === this._maxListeners) { throw new Error(process.env.NODE_ENV === "production" ? formatProdErrorMessage(1) : 'Max listeners reached'); } this._handlers[event] = this._handlers[event] || []; const onceWrapper = (...args) => { listener(...args); this.off(event, onceWrapper); }; this._handlers[event].push(onceWrapper); return this; }; removeListener = (event, listener) => { if (this._handlers[event]) { this._handlers[event] = this._handlers[event].filter(handler => handler !== listener); } return this; }; off = (event, listener) => this.removeListener(event, listener); removeAllListeners = event => { if (event) { delete this._handlers[event]; } else { this._handlers = {}; this._globalHandlers = []; } return this; }; setMaxListeners = n => { this._maxListeners = n; return this; }; getMaxListeners = () => this._maxListeners; listeners = event => { if (this._handlers[event]) { return [...this._handlers[event]]; } return []; }; rawListeners = event => [...this._handlers[event]]; listenerCount = event => { if (this._handlers[event]) { return Object.keys(this._handlers[event]).length; } return 0; }; prependListener = (event, listener) => { if (Object.keys(this._handlers).length === this._maxListeners) { throw new Error(process.env.NODE_ENV === "production" ? formatProdErrorMessage(1) : 'Max listeners reached'); } this._handlers[event] = this._handlers[event] || []; this._handlers[event].unshift(listener); return this; }; prependOnceListener = (event, listener) => { if (Object.keys(this._handlers).length === this._maxListeners) { throw new Error(process.env.NODE_ENV === "production" ? formatProdErrorMessage(1) : 'Max listeners reached'); } this._handlers[event] = this._handlers[event] || []; const onceWrapper = (...args) => { listener(...args); this.off(event, onceWrapper); }; this._handlers[event].unshift(onceWrapper); return this; }; eventNames = () => Object.keys(this._handlers); on = (event, listener) => this.addListener(event, listener); emit = (event, ...args) => { const listeners = this.listeners(event); if (listeners.length > 0) { listeners.forEach(listener => listener(args)); return true; } this._globalHandlers.forEach(listener => listener(event, args)); return false; }; } const encodePacket = packet => Buffer.from(JSON.stringify(packet)); const decodePacket = buffer => { const str = Buffer.from(buffer).toString(); try { return JSON.parse(str); } catch (error) { throw new Error(process.env.NODE_ENV === "production" ? formatProdErrorMessage(2) : `Invalid packet format: ${str}`); } }; const EVENT = 0; const ACK = 1; class UWSSocket extends SocketEventEmitter { _id; _ws; _fnId = 0; _acks = new Map(); _maxListeners = 50; _debug; _closed = false; _handlers = {}; get id() { return this._id; } get ws() { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return this._ws; } constructor(id, ws, verbose) { super(); this._id = id; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this._ws = ws; this._debug = verbose; } join = group => { if (this._debug) console.log(`${this._id} joining group ${group}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call this._ws.subscribe(group); return this; }; leave = group => { if (this._debug) console.log(`${this._id} left group ${group}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call this._ws.unsubscribe(group); return this; }; send = (...args) => { args.unshift('message'); return this._send({ type: EVENT, data: args }); }; isClosed = () => { return this._closed; }; emit = (event, ...args) => { args.unshift(event); const packet = { type: EVENT, data: args }; if (typeof args[args.length - 1] === 'function') { this._acks.set(this._fnId, args.pop()); packet.id = this._fnId; this._fnId += 1; } return this._send(packet); }; _send = packet => { if (this._debug) console.log(`Sending packet to ${this._id}: ${JSON.stringify(packet.data)}`); if (!this._closed) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call return this._ws.send(encodePacket(packet)) !== 2; } return false; }; _ack = id => { let sent = false; return (...args) => { if (sent) return; this._send({ type: ACK, data: args, id }); sent = true; }; }; onMessage = buffer => { try { const packet = decodePacket(buffer); if (this._debug) console.log(`Got packet from ${this._id}: ${JSON.stringify(packet.data)}`); if (packet.type === EVENT) { const event = packet.data[0]; if (this._handlers[event]) { const args = packet.data.slice(1); if (packet.id !== undefined) { // Replace last arg with callback args.push(this._ack(packet.id)); } try { this._handlers[event].forEach(handler => handler(...args)); } catch (eventError) { console.error(eventError); } } } else if (packet.type === ACK && packet.id !== undefined) { // Call assigned function const ack = this._acks.get(packet.id); if (typeof ack === 'function') { ack.apply(this, packet.data); this._acks.delete(packet.id); } } else { console.error(`Unknown packet: ${packet.type}`); } } catch (messageError) { console.error(messageError); } }; onDisconnect = () => { if (this._debug) console.log(`Client ${this._id} disconnected`); this._closed = true; if (this._handlers.disconnect) { this._handlers.disconnect.forEach(handler => handler()); } }; error = message => this.emit('error', message); disconnect = () => { if (this._debug) console.log(`Disconnecting ${this._id}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call this._ws.close(); this._closed = true; return this; }; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return getUserData = key => this._ws[key]; } const requireModule = node_module.createRequire((typeof document === 'undefined' ? new (require('u' + 'rl').URL)('file:' + __filename).href : (document.currentScript && document.currentScript.src || new URL('teckos.js', document.baseURI).href))); exports.ListenOptions = void 0; (function (ListenOptions) { ListenOptions[ListenOptions["LIBUS_LISTEN_DEFAULT"] = 0] = "LIBUS_LISTEN_DEFAULT"; ListenOptions[ListenOptions["LIBUS_LISTEN_EXCLUSIVE_PORT"] = 1] = "LIBUS_LISTEN_EXCLUSIVE_PORT"; })(exports.ListenOptions || (exports.ListenOptions = {})); const uws = (() => { try { const p = `../bin/uws_${process.platform}_${process.arch}_${process.versions.modules}.node`; return requireModule(p); } catch (e) { throw new Error(process.env.NODE_ENV === "production" ? formatProdErrorMessage(0) : `This version of uWS.js supports only Node.js 14, 16 and 18 on (glibc) Linux, macOS and Windows, on Tier 1 platforms (https://github.com/nodejs/node/blob/master/BUILDING.md#platform-list).\n\n${e.toString()}`); } })(); /* eslint-disable no-console */ const DEFAULT_OPTION = { pingInterval: 25000, pingTimeout: 5000 }; function generateUUID() { return crypto__namespace.randomBytes(16).toString('hex'); } class UWSProvider { _app; _options; _pub; _sub; _connections = {}; // private _handlers: ITeckosSocketHandler[] = [] _handler; constructor(app, options) { this._app = app; this._options = { redisUrl: options?.redisUrl || undefined, pingInterval: options?.pingInterval || DEFAULT_OPTION.pingInterval, pingTimeout: options?.pingInterval || DEFAULT_OPTION.pingTimeout, debug: options?.debug }; const { redisUrl } = this._options; if (redisUrl) { if (this._options.debug) console.log(`Using REDIS at ${redisUrl}`); // eslint-disable-next-line new-cap this._pub = new Redis__default["default"](redisUrl); // eslint-disable-next-line new-cap this._sub = new Redis__default["default"](redisUrl); // All publishing // eslint-disable-next-line @typescript-eslint/no-floating-promises this._sub.subscribe('a', err => err && console.error(err.message)); // Since we are only subscribing to a, // no further checks are necessary (trusting ioredis here) this._sub.on('messageBuffer', (_channel, buffer) => { // Should only be a, so ... if (this._options.debug) { console.log(`Publishing message from REDIS to all`); } this._app.publish('a', buffer); }); // Group publishing // eslint-disable-next-line @typescript-eslint/no-floating-promises this._sub.psubscribe('g.*', 'd.*', err => err && console.error(err.message)); // Since we are only p-subscribing to g.* and d.*, // no further checks are necessary (trusting ioredis here) this._sub.on('pmessageBuffer', (_pattern, channelBuffer, message) => { const channel = channelBuffer.toString(); const action = channel.charAt(0); const group = channel.substring(2); if (action === 'd') { if (this._options.debug) { console.log(`Disconnecting everybody in group ${group} due to message from REDIS`); } this._disconnectGroup(group); } else if (action === 'g') { if (this._options.debug) { console.log(`Publishing message from REDIS to group ${group}`); } this._app.publish(group, message); } }); } this._app.ws('/*', { /* Options */ compression: uws.SHARED_DECOMPRESSOR, maxPayloadLength: 16 * 1024 * 1024, idleTimeout: 0, maxBackpressure: 1024, open: ws => { const id = generateUUID(); /* Let this client listen to all sensor topics */ // Subscribe to all ws.subscribe('a'); // eslint-disable-next-line no-param-reassign ws.id = id; // eslint-disable-next-line no-param-reassign ws.alive = true; this._connections[id] = new UWSSocket(id, ws, options?.debug); if (this._handler) { try { this._handler(this._connections[id]); } catch (handlerError) { // eslint-disable-next-line no-console console.error(handlerError); } } }, pong: ws => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,no-param-reassign ws.alive = true; }, message: (ws, buffer) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const id = ws.id; if (this._connections[id]) { this._connections[id].onMessage(buffer); } else { console.error(`Got message from unknown connection: ${id}`); } }, drain: ws => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const id = ws.id; if (options?.debug) console.log(`Drain: ${id}`); }, close: ws => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const id = ws.id; if (this._connections[id]) { if (this._options.debug) { console.log(`Client ${id} disconnected, removing from registry`); } this._connections[id].onDisconnect(); delete this._connections[id]; } } }); // Add ping this._keepAliveSockets(); } _keepAliveSockets = () => { setTimeout(connections => { Object.keys(connections).forEach(uuid => { const { ws } = this._connections[uuid]; if (ws.alive) { ws.alive = false; ws.ping('hey'); } else { // Terminate connection if (this._options.debug) { console.log(`Ping pong timeout for ${uuid}, disconnecting client...`); } this._connections[uuid].disconnect(); } }); this._keepAliveSockets(); }, this._options.pingInterval, this._connections); }; _disconnectGroup = group => { const connections = Object.values(this._connections); connections.forEach(connection => { if (connection.ws.isSubscribed(group)) { connection.disconnect(); } }); }; onConnection = handler => { this._handler = handler; return this; }; toAll = (event, ...args) => { args.unshift(event); const buffer = encodePacket({ type: EVENT, data: args }); if (this._pub) { this._pub.publish('a', buffer).catch(err => console.error(err)); } else { if (this._options.debug) console.log(`Publishing event ${event} to all`); this._app.publish('a', buffer); } return this; }; to = (group, event, ...args) => { args.unshift(event); const buffer = encodePacket({ type: EVENT, data: args }); if (this._pub) { this._pub.publish(`g.${group}`, buffer).catch(err => console.error(err)); } else { if (this._options.debug) console.log(`Publishing event ${event} to group ${group}`); this._app.publish(group, buffer); } return this; }; disconnect(group) { if (this._pub) { this._pub.publish(`d.${group}`, '').catch(err => console.error(err)); } else { if (this._options.debug) console.log(`Disconnecting whole group ${group}`); this._disconnectGroup(group); } return this; } listen = port => new Promise((resolve, reject) => { this._app.listen(port, socket => { if (socket) { return resolve(this); } return reject(new Error(`Could not listen on port ${port}`)); }); }); } /* * Copyright (c) 2021 Tobias Hegemann * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ class UWSProviderWithToken extends UWSProvider { constructor(app, tokenHandler, options) { super(app, options); this.onConnection(socket => { socket.once('token', ({ token, ...others }) => { if (token && typeof token === 'string') { Promise.resolve(tokenHandler(token, others)).then(result => { if (result) { return socket.emit('ready'); } return socket.disconnect(); }).catch(() => socket.disconnect()); return undefined; } return socket.disconnect(); }); }); } } exports.UWSProvider = UWSProvider; exports.UWSProviderWithToken = UWSProviderWithToken; exports.UWSSocket = UWSSocket; exports.uws = uws;