UNPKG

@interopio/gateway-server

Version:

## `@glue42/gateway-ent` compatibility

317 lines (299 loc) 13.7 kB
import {WebSocketServer} from 'ws'; import http from 'node:http'; import https from 'node:https'; import {SecureContextOptions} from 'node:tls'; import {AddressInfo} from 'node:net'; import {readFileSync} from 'node:fs'; import {AsyncLocalStorage} from 'node:async_hooks'; import {IOGateway} from '@interopio/gateway'; import wsGateway from './gateway/ws/core.js'; import NodeConnections from './mesh/connections.js'; import restDirectory from './mesh/rest-directory/routes.js'; import meshBroker from './mesh/ws/broker/core.js'; import meshRelays from './mesh/ws/relays/core.js'; import meshCluster from './mesh/ws/cluster/core.js'; import metrics from './metrics/routes.js'; import {compose} from './common/compose.js'; import {WebExchange, Middleware} from './server/types.js'; import {DefaultWebExchange, HttpServerRequest, HttpServerResponse} from './server/exchange.js'; import {socketKey} from './utils.js'; import getLogger from './logger.js'; import {localIp, portRange} from './server/address.js'; import * as monitoring from './server/monitoring.js'; import {acceptsOrigin, ProcessedOriginFilters, regexifyOriginFilters} from './server/ws-client-verify.js'; import {GatewayServer} from '../gateway-server'; import cors from './server/cors.ts'; const logger = getLogger('app'); function secureContextOptions(ssl: GatewayServer.SslConfig): SecureContextOptions { const options: SecureContextOptions = {}; if (ssl.key) options.key = readFileSync(ssl.key); if (ssl.cert) options.cert = readFileSync(ssl.cert); if (ssl.ca) options.ca = readFileSync(ssl.ca); return options; } type RequestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void; function createListener(middleware: Middleware<HttpServerRequest, HttpServerResponse>, routes: Map<string, RouteInfo>) { const storage = new AsyncLocalStorage(); const listener = compose<WebExchange<HttpServerRequest, HttpServerResponse>>( async ({response}, next) => { response.headers.set('server', 'gateway-server'); await next(); }, ...cors({ origins: {allow: [/http:\/\/localhost(:\d+)?/]}, methods: {allow: ['GET', 'HEAD', 'POST', 'DELETE']}, headers: {allow: '*'}, credentials: {allow: true} }), ...middleware, async ({request, response}, next) => { if (request.method === 'GET' && request.path === '/health') { response.statusCode = 200; response._res.end(http.STATUS_CODES[200]); } else { await next(); } }, async ({request, response}, next) => { if (request.method === 'GET' && request.path === '/') { response._res.end(`io.Gateway Server`); } else { await next(); } }, async ({request, response}, _next) => { const route = routes.get(request.path); if (route) { response.statusCode = 426; response._res .appendHeader('Upgrade', 'websocket') .appendHeader('Connection', 'Upgrade') .appendHeader('Content-Type', 'text/plain'); response._res.end(`This service [${request.path}] requires use of the websocket protocol.`); } else { response.statusCode = 404; response._res.end(http.STATUS_CODES[404]); } } ); return (request: http.IncomingMessage, response: http.ServerResponse) => { const exchange = new DefaultWebExchange(new HttpServerRequest(request), new HttpServerResponse(response)); return storage.run(exchange, async () => { if (logger.enabledFor('debug')) { const socket = exchange.request._req.socket; if (logger.enabledFor('debug')) { logger.debug(`received ${exchange.method} request for ${exchange.path} from ${socket.remoteAddress}:${socket.remotePort}`); } } return await listener(exchange); }); }; } function promisify<T>(fn: (callback?: (err?: Error) => void) => T): Promise<T> { return new Promise<T>((resolve, reject) => { const r = fn((err?: Error) => { if (err) { reject(err); } else { resolve(r); } }); }); } type RouteInfo = { readonly default?: boolean, readonly ping?: number, readonly maxConnections?: number readonly originFilters?: ProcessedOriginFilters readonly factory: (server: { endpoint: string, wss: WebSocketServer }) => Promise<{ info?: string, close?: () => Promise<void> }>, // set later in listening wss?: WebSocketServer, close?: () => Promise<void> } function memoryMonitor(config?: GatewayServer.ServerConfig['memory']) { if (config) { return monitoring.start({ memoryLimit: config.memory_limit, dumpLocation: config.dump_location, dumpPrefix: config.dump_prefix, reportInterval: config.report_interval, maxBackups: config.max_backups }); } } function regexAwareReplacer<T>(_key: string, value: T): string | T { return value instanceof RegExp ? value.toString() : value; } export const Factory = async (options: GatewayServer.ServerConfig): Promise<GatewayServer.Server> => { const ssl = options.ssl; const createServer = ssl ? (options: http.ServerOptions, handler: RequestHandler) => https.createServer({...options, ...secureContextOptions(ssl)}, handler) : (options: http.ServerOptions, handler: RequestHandler) => http.createServer(options, handler); const monitor = memoryMonitor(options.memory); const middleware: Middleware<HttpServerRequest, HttpServerResponse> = []; const routes: Map<string, RouteInfo> = new Map<string, RouteInfo>(); const gw = IOGateway.Factory({...options.gateway}); if (options.gateway) { const config = options.gateway; routes.set(config.route ?? '/', { default: config.route === undefined, ping: options.gateway.ping, factory: wsGateway.bind(gw), maxConnections: config.limits?.max_connections, originFilters: regexifyOriginFilters(config.origins) }); } if (options.mesh) { const connections = new NodeConnections(options.mesh.timeout ?? 60000); middleware.push(...restDirectory(connections)); const ping = options.mesh.ping ?? 30000; routes.set('/broker', {factory: meshBroker, ping: ping}); routes.set('/cluster', {factory: meshCluster, ping: ping}); routes.set('/relays', {factory: meshRelays, ping: ping}); } if (options.metrics) { middleware.push(...(await metrics(options.metrics))); } const ports = portRange(options.port ?? 0); const host = options.host; const serverP: Promise<http.Server> = new Promise((resolve, reject) => { const onSocketError = (err: Error) => logger.error(`socket error: ${err}`, err); const server = createServer( {}, createListener(middleware, routes)); server.on('error', (e: Error) => { if (e['code'] === 'EADDRINUSE') { logger.debug(`port ${e['port']} already in use on address ${e['address']}`); const {value: port} = ports.next(); if (port) { logger.info(`retry starting server on port ${port} and host ${host ?? '<unspecified>'}`); server.close(); server.listen(port, host); } else { logger.warn(`all configured port(s) ${options.port} are in use. closing...`); server.close(); reject(e); } } else { logger.error(`server error: ${e.message}`, e); reject(e); } }); server .on('listening', async () => { const info = server.address() as AddressInfo; for (const [path, route] of routes) { try { logger.info(`creating ws server for [${path}]. max connections: ${route.maxConnections ?? '<unlimited>'}, origin filters: ${route.originFilters ? JSON.stringify(route.originFilters, regexAwareReplacer) : '<none>'}`); const wss = new WebSocketServer({noServer: true}); const endpoint = `${ssl ? 'wss' : 'ws'}://${localIp}:${info.port}${path}`; const handler = await route.factory({endpoint, wss}); const pingInterval = route.ping; if (pingInterval) { const pingIntervalId = setInterval(() => { for (const client of wss.clients) { if (client['connected'] === false) { client.terminate(); } client['connected'] = false; client.ping(); } }, pingInterval); wss.on('close', () => { clearInterval(pingIntervalId); }); } route.wss = wss; route.close = handler.close?.bind(handler); } catch (e) { logger.warn(`failed to init route ${path}`, e); } } logger.info(`http server listening on ${info.address}:${info.port}`); resolve(server); }); server .on('upgrade', (req, socket, head) => { socket.addListener('error', onSocketError); try { const request = new HttpServerRequest(req); const path = request.path ?? '/'; const route = (routes.get(path) ?? Array.from(routes.values()).find(route => { if (path === '/' && route.default === true) { return true; } })); const host = request.host; const info = socketKey(request.socket); if (route?.wss) { socket.removeListener('error', onSocketError); const wss = route.wss; if (route.maxConnections !== undefined && wss.clients?.size >= route.maxConnections) { logger.warn(`${info} dropping ws connection request from ${host} on ${path}. max connections exceeded.`); socket.destroy(); return; } const origin = request.headers['origin']; if (!acceptsOrigin(origin, route.originFilters)) { logger.info(`${info} dropping ws connection request from ${host} on ${path}. origin ${origin ?? '<missing>'}`); socket.destroy(); return; } if (logger.enabledFor('debug')) { logger.debug(`${info} accepted new ws connection request from ${host} on ${path}`); } wss.handleUpgrade(req, socket, head, (ws) => { ws.on('pong', () => ws['connected'] = true); ws.on('ping', () => { }); wss.emit('connection', ws, req); }); } else { logger.warn(`${info} rejected upgrade request from ${host} on ${path}`); socket.destroy(); } } catch (err) { logger.error(`upgrade error: ${err}`, err); } }) .on('close', async () => { logger.info(`http server closed.`); }); try { const {value: port} = ports.next(); server.listen(port, host); } catch (e) { logger.error(`error starting web socket server`, e); reject(e instanceof Error ? e : new Error(`listen failed: ${e}`)); } }); const server = await serverP; return new class implements GatewayServer.Server { readonly gateway = gw; async close(): Promise<void> { for (const [path, route] of routes) { try { if (route.close) { await route.close(); } logger.info(`stopping ws server for [${path}]. clients: ${route.wss?.clients?.size ?? 0}`); route.wss?.clients?.forEach(client => { client.terminate(); }); route.wss?.close(); } catch (e) { logger.warn(`error closing route ${path}`, e); } } await promisify(cb => { server.closeAllConnections(); server.close(cb); }); if (monitor) { await monitoring.stop(monitor); } } } }