UNPKG

acebase-ipc-server

Version:

IPC Server that provides communication between isolated AceBase processes using the same database files, such as local pm2 and cloud-based clusters.

362 lines (322 loc) 15.8 kB
import uWS from 'uWebSockets.js'; export interface AceBaseIPCServerConfig { host?: string, port: number, /** Used to check if connections made to this server are using the right database */ // dbname: string, /** Provide SSL certificate details. Use either `certPath` and `keyPath`, or `pfxPath` and `passphrase` */ ssl?: { certPath?: string, keyPath?: string pfxPath?: string, passphrase?: string }, /** * Maximum amount of bytes allowed to be sent over the websocket connection. The websocket connection is closed * immediately if the payload exceeds this number. Default is 16KB (16384). * Clients should send messages with larger content over http(s) with POST `/[dbname]/send?id=[clientId]`; * to receive large messages the server will send `get:[msgId]` to the client over the websocket * connection, the message can then be downloaded by calling GET `/[dbname]/receive?id=[clientId]&msgId=[id]` */ maxPayload?: number /** secret token to (help) prevent unauthorized clients to use the IPC channel */ token?: string } interface AceBaseIPCClient { id: string, dbname: string, connected: Date, ws: uWS.WebSocket, sendMessage(message: any): Promise<void> } /** * This flow is used for remote IPC communications * Handshake: * - Remote client connects to the websocket on url `/[dbname]/connect?id=[clientId]&v=[clientVersion]&t=[token]` * - IPC Server adds it to the `clients` list for that dbname, if another client with the same id exists already, it's previous connection will be closed * - IPC Server sends `"welcome:{ maxPayload: [maxPayload] }"` to the client to notify the maxPayload size to use * - IPC Server broadcasts `"connect:clientid"` * * Connection checks: * - To check the connection, client can send a `"ping"` message, which will immediately be replied to with `"pong"` * * Message sending: * - If a client wants to send a message to 1 specific peer, it should prefix the message with `"to:[peerId];"` * - Messages without prefix are broadcast to all other peers * - If a message is prefixed `"to:all;"` the message will be sent to all other peers individually, this is provided for testing only - use unprefixed instead * - If the message to send exceeds the configured payload size, it must be http(s) POSTed to "/send?id=[clientId]&t=[token]" instead * * Message receiving: * - Clients receive small messages through the websocket connection. * - Messages sent from other peers will be prefixed with `"msg:"` * - Messages too large to be sent over the websocket connection, will send `"get:[msgId]"` instead, client must http(s) GET `"/[dbname]/receive?id=[clientId]&msg=[msgId]&t=[token]"` to download the message * * Disconnect: * - Upon disconnection of a remote peer, server broadcasts `"disconnect:clientid"` to all still connected * */ export class AceBaseIPCServer { private clients: { [dbname: string]: AceBaseIPCClient[] } = {}; constructor(private config: AceBaseIPCServerConfig) {} getClients(dbname: string) { if (!(dbname in this.clients)) { this.clients[dbname] = []; } return this.clients[dbname]; } start(): Promise<void> { let resolve:()=>void, reject:(err:Error)=>void, promise = new Promise<void>((rs, rj) => { resolve = rs; reject = rj; }); const config = this.config; if (typeof config.maxPayload !== 'number') { config.maxPayload = 16 * 1024; } const textDecoder = new TextDecoder(); const app = config.ssl ? uWS.SSLApp({ cert_file_name: config.ssl.certPath, key_file_name: config.ssl.keyPath, dh_params_file_name: config.ssl.pfxPath, passphrase: config.ssl.passphrase }) : uWS.App(); app.ws(`/:dbname/connect`, { idleTimeout: 0, // No timeout maxBackpressure: 1024 * 1024, // default maxPayloadLength: config.maxPayload, // default (16 * 1024), connection is closed when payload exceeds this compression: uWS.DISABLED, // default upgrade: (res, req, context) => { // Execute the upgrade manually to add url and query const dbname = req.getParameter(0); const url = req.getUrl(), query = req.getQuery(); // console.log(`Websocket request for db "${dbname}"`); // Parse query, should be in the form 'id=clientid&v=1' const env = parseQuery(query as string); // Check client environment let err; if (typeof env.v !== 'string' || env.v.split('.')[0] !== '1') { // Using semantic versioning, major version update means and update is needed, minor version bump indicates backward compatible features were added, build nr bump means bugfix. // This server version allows version 1.x.x err = `409 Unsupported client IPC version "${env.v}". Update acebase-ipc-server package`; } else if (typeof env.id !== 'string' || env.id.length < 5) { err = `500 Invalid IPC client id ${env.id}`; } else if (typeof config.token === 'string' && env.t !== config.token) { err = `403 Unauthorized`; } if (err) { console.error(err); res.writeStatus(err); return res.end(err); } const clients = this.getClients(dbname); const existingClient = clients.find(client => client.id === env.id); if (existingClient) { // New client is connecting with an already known id. Did we not get notified about a previous disconnect? // Close it now, it'll be replaced by the new connection console.warn(`Client ${env.id} is connecting, but a previous connection appears to be open. Closing previous connection now.`) existingClient.ws.close(); } res.upgrade({ url, query, env, dbname }, /* Spell these correctly */ req.getHeader('sec-websocket-key'), req.getHeader('sec-websocket-protocol'), req.getHeader('sec-websocket-extensions'), context ); }, open: (ws) => { // Add new client const client:AceBaseIPCClient = { connected: new Date(), id: ws.env.id, dbname: ws.dbname, ws, async sendMessage(msg: any) { const data = typeof msg === 'string' ? msg : `msg:${JSON.stringify(msg)}`; const success = this.ws.send(data, false, false); if (!success) { console.warn(`Back pressure on client ${this.id} is building up`); } } }; // Subscribe clients to each others broadcast channels called "from[id]" const clients = this.getClients(ws.dbname); clients.forEach(client => { // Subscribe this client to broadcast messages from other clients ws.subscribe(`from-${ws.dbname}-${client.id}`); // Subscribe others to receive broadcast messages from this client client.ws.subscribe(`from-${ws.dbname}-${ws.env.id}`); }); // Add new client clients.push(client); // Send welcome message with configuration ws.send(`welcome:` + JSON.stringify({ maxPayload: config.maxPayload })); // Publish connect event to other clients app.publish('all', `connect:${client.id}`, false, false); // subscribe websocket to broadcasted events meant for all (connect & disconnect) ws.subscribe('all'); }, close: (ws, code, message) => { // Remove client const clients = this.getClients(ws.dbname); const index = clients.findIndex(client => client.ws === ws); if (index >= 0) { const client = clients[index]; index >= 0 && clients.splice(index, 1); app.publish('all', `disconnect:${client.id}`, false, false); } }, message: (ws, buffer, isBinary) => { if (isBinary) { return; } // Ignore const client = this.getClients(ws.dbname).find(client => client.ws === ws); try { const str = textDecoder.decode(buffer); console.log(`Received websocket message from ${client?.id} on db "${ws.dbname}": "${str}"`); this.handleIncomingMessage(str, ws); } catch(err) { console.error(`Error parsing received websocket message:`, err); } }, }); app.get(`/:dbname/clients`, (res, req) => { const dbname = req.getParameter(0); const clients = this.getClients(dbname); const txt = JSON.stringify(clients.map(client => ({ id: client.id, connected: client.connected.getTime() }))); res.end(txt); }); app.post(`/:dbname/send`, (res, req) => { // Client sending large message // example POST /mydb/receive?id=client1&token=secret (with message in data) const query = parseQuery(req.getQuery()); const dbname = req.getParameter(0); const clients = this.getClients(dbname); const client = clients.find(client => client.id === query.id); if (!client || (typeof config.token === 'string' && query.t !== config.token)) { res.writeStatus('401 Unauthorized'); return res.end('Unauthorized'); } let data = ''; res.onData((chunk, isLast) => { data += textDecoder.decode(chunk); if (isLast) { res.end('ok'); this.handleIncomingMessage(data, client.ws); } }); }); /** * FOR TESTING PURPOSES ONLY, DISABLED IN PRODUCTION ENVIRONMENT * GET /mydb/send?id=client1&token=secret&msg=to:client1;Hallo */ app.get(`/:dbname/send`, (res, req) => { if (process.env?.NODE_ENV !== 'development') { res.writeStatus('405 Method Not Allowed'); return res.end('405 Method Not Allowed'); } const query = parseQuery(req.getQuery()); const dbname = req.getParameter(0); const clients = this.getClients(dbname); const client = clients.find(client => client.id === query.id); if (!client || (typeof config.token === 'string' && query.t !== config.token)) { res.writeStatus('401 Unauthorized'); return res.end('Unauthorized'); } this.handleIncomingMessage(query.msg, client.ws); }); app.get(`/:dbname/receive`, (res, req) => { // Client wants to download a large message // example GET /mydb/receive?id=client1&msg=12345&token=secret const query = parseQuery(req.getQuery()); const dbname = req.getParameter(0); const clients = this.getClients(dbname); const client = clients.find(client => client.id === query.id); if (!client || (typeof config.token === 'string' && query.t !== config.token)) { res.writeStatus('401 Unauthorized'); return res.end('Unauthorized'); } const msg = this.largeMessages[query.msg]; if (typeof msg !== 'string') { res.writeStatus('404 Not Found'); res.end('Not Found'); } else { delete this.largeMessages[query.msg]; res.end(msg); } }) app.listen(config.port, listenSocket => { if (listenSocket) { console.log(`AceBase IPC server running on port ${config.port}`); resolve(); } else { const message = `AceBase IPC server failed to start`; console.error(message); reject(new Error(message)); } }); return promise; } largeMessages: { [id:string]: string } = {}; handleIncomingMessage(msg: string, ws: uWS.WebSocket) { const clients = this.getClients(ws.dbname); let to:string = ''; if (msg === 'ping') { return ws.send('pong'); } if (msg.startsWith('to:')) { // Message as an explicit recipient, format is "to:client1;message" let i = msg.indexOf(';'); to = msg.slice(3, i); msg = msg.slice(i+1); } if (msg.length > (this.config.maxPayload as number)) { // Message too large to send over websocket connection const id = generateID(); this.largeMessages[id] = msg; // Remove message if not downloaded within 60s setTimeout(() => { delete this.largeMessages[id]; }, 60e3); // Adjust message to download instruction for client msg = `get:${id}`; } if (to.length > 0) { // Forward message to recipient or all others const forwardTo = to === 'all' ? clients.filter(client => client.ws !== ws) : clients.filter(client => client.id === to); forwardTo.forEach(client => { client.sendMessage(msg); }); } else { // Broadcast entire message to all others const client = clients.find(client => client.ws === ws); if (client) { ws.publish(`from-${client.dbname}-${client.id}`, msg, false, false); } else { console.warn(`Received message from unknown client`); } } } } function parseQuery(q: string) { return q.split('&').reduce((init, kvp) => { let pair = kvp.split('='); init[pair[0]] = pair[1]; return init; }, {} as { [key:string]: any }); } let _idSequence = 0; const _maxNr = Math.pow(36, 8); function generateID() { if (++_idSequence === _maxNr) { _idSequence = 0; } const time = Date.now().toString(36).padStart(8, '0'); const seq = _idSequence.toString(36).padStart(8, '0'); const random = Math.floor(Math.random() * _maxNr).toString(36).padStart(8, '0'); return `${time}${seq}${random}`; }