UNPKG

@interopio/gateway-server

Version:

## `@glue42/gateway-ent` compatibility

4 lines 117 kB
{ "version": 3, "sources": ["../src/index.ts", "../src/server.ts", "../src/logger.ts", "../src/utils.ts", "../src/server/types.ts", "../src/server/exchange.ts", "../src/gateway/ws/core.ts", "../src/mesh/connections.ts", "../src/mesh/rest-directory/routes.ts", "../src/mesh/ws/broker/core.ts", "../src/mesh/ws/relays/core.ts", "../src/mesh/ws/cluster/core.ts", "../src/metrics/routes.ts", "../src/common/compose.ts", "../src/server/address.ts", "../src/server/monitoring.ts", "../src/server/ws-client-verify.ts", "../src/server/cors.ts"], "sourcesContent": ["import * as GatewayServer from './server.js';\nexport {GatewayServer};\nexport default GatewayServer.Factory;\n", "import {WebSocketServer} from 'ws';\nimport http from 'node:http';\nimport https from 'node:https';\nimport {SecureContextOptions} from 'node:tls';\nimport {AddressInfo} from 'node:net';\nimport {readFileSync} from 'node:fs';\nimport {AsyncLocalStorage} from 'node:async_hooks';\nimport {IOGateway} from '@interopio/gateway';\nimport wsGateway from './gateway/ws/core.js';\nimport NodeConnections from './mesh/connections.js';\nimport restDirectory from './mesh/rest-directory/routes.js';\nimport meshBroker from './mesh/ws/broker/core.js';\nimport meshRelays from './mesh/ws/relays/core.js';\nimport meshCluster from './mesh/ws/cluster/core.js';\nimport metrics from './metrics/routes.js';\nimport {compose} from './common/compose.js';\nimport {WebExchange, Middleware} from './server/types.js';\nimport {DefaultWebExchange, HttpServerRequest, HttpServerResponse} from './server/exchange.js';\nimport {socketKey} from './utils.js';\nimport getLogger from './logger.js';\nimport {localIp, portRange} from './server/address.js';\nimport * as monitoring from './server/monitoring.js';\nimport {acceptsOrigin, ProcessedOriginFilters, regexifyOriginFilters} from './server/ws-client-verify.js';\nimport {GatewayServer} from '../gateway-server';\nimport cors from './server/cors.ts';\n\nconst logger = getLogger('app');\n\nfunction secureContextOptions(ssl: GatewayServer.SslConfig): SecureContextOptions {\n const options: SecureContextOptions = {};\n if (ssl.key) options.key = readFileSync(ssl.key);\n if (ssl.cert) options.cert = readFileSync(ssl.cert);\n if (ssl.ca) options.ca = readFileSync(ssl.ca);\n return options;\n}\n\ntype RequestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void;\n\nfunction createListener(middleware: Middleware<HttpServerRequest, HttpServerResponse>,\n routes: Map<string, RouteInfo>) {\n const storage = new AsyncLocalStorage();\n\n const listener = compose<WebExchange<HttpServerRequest, HttpServerResponse>>(\n async ({response}, next) => {\n response.headers.set('server', 'gateway-server');\n await next();\n },\n ...cors({\n origins: {allow: [/http:\\/\\/localhost(:\\d+)?/]},\n methods: {allow: ['GET', 'HEAD', 'POST', 'DELETE']},\n headers: {allow: '*'},\n credentials: {allow: true}\n }),\n ...middleware,\n async ({request, response}, next) => {\n if (request.method === 'GET' && request.path === '/health') {\n response.statusCode = 200;\n response._res.end(http.STATUS_CODES[200]);\n } else {\n await next();\n }\n },\n async ({request, response}, next) => {\n if (request.method === 'GET' && request.path === '/') {\n response._res.end(`io.Gateway Server`);\n } else {\n await next();\n }\n },\n async ({request, response}, _next) => {\n const route = routes.get(request.path);\n if (route) {\n response.statusCode = 426;\n response._res\n .appendHeader('Upgrade', 'websocket')\n .appendHeader('Connection', 'Upgrade')\n .appendHeader('Content-Type', 'text/plain');\n response._res.end(`This service [${request.path}] requires use of the websocket protocol.`);\n } else {\n response.statusCode = 404;\n response._res.end(http.STATUS_CODES[404]);\n }\n }\n );\n\n return (request: http.IncomingMessage, response: http.ServerResponse) => {\n const exchange = new DefaultWebExchange(new HttpServerRequest(request), new HttpServerResponse(response));\n return storage.run(exchange, async () => {\n if (logger.enabledFor('debug')) {\n const socket = exchange.request._req.socket;\n if (logger.enabledFor('debug')) {\n logger.debug(`received ${exchange.method} request for ${exchange.path} from ${socket.remoteAddress}:${socket.remotePort}`);\n }\n }\n return await listener(exchange);\n });\n };\n}\n\nfunction promisify<T>(fn: (callback?: (err?: Error) => void) => T): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const r = fn((err?: Error) => {\n if (err) {\n reject(err);\n } else {\n resolve(r);\n }\n });\n });\n}\n\ntype RouteInfo = {\n readonly default?: boolean,\n readonly ping?: number,\n readonly maxConnections?: number\n readonly originFilters?: ProcessedOriginFilters\n readonly factory: (server: { endpoint: string, wss: WebSocketServer }) => Promise<{\n info?: string,\n close?: () => Promise<void>\n }>,\n // set later in listening\n wss?: WebSocketServer,\n close?: () => Promise<void>\n}\n\nfunction memoryMonitor(config?: GatewayServer.ServerConfig['memory']) {\n if (config) {\n return monitoring.start({\n memoryLimit: config.memory_limit,\n dumpLocation: config.dump_location,\n dumpPrefix: config.dump_prefix,\n reportInterval: config.report_interval,\n maxBackups: config.max_backups\n });\n }\n}\n\nfunction regexAwareReplacer<T>(_key: string, value: T): string | T {\n return value instanceof RegExp ? value.toString() : value;\n}\n\nexport const Factory = async (options: GatewayServer.ServerConfig): Promise<GatewayServer.Server> => {\n const ssl = options.ssl;\n const createServer = ssl ? (options: http.ServerOptions, handler: RequestHandler) => https.createServer({...options, ...secureContextOptions(ssl)}, handler) : (options: http.ServerOptions, handler: RequestHandler) => http.createServer(options, handler);\n const monitor = memoryMonitor(options.memory);\n const middleware: Middleware<HttpServerRequest, HttpServerResponse> = [];\n const routes: Map<string, RouteInfo> = new Map<string, RouteInfo>();\n const gw = IOGateway.Factory({...options.gateway});\n if (options.gateway) {\n const config = options.gateway;\n routes.set(config.route ?? '/', {\n default: config.route === undefined,\n ping: options.gateway.ping,\n factory: wsGateway.bind(gw),\n maxConnections: config.limits?.max_connections,\n originFilters: regexifyOriginFilters(config.origins)\n });\n }\n if (options.mesh) {\n const connections = new NodeConnections(options.mesh.timeout ?? 60000);\n middleware.push(...restDirectory(connections));\n const ping = options.mesh.ping ?? 30000;\n routes.set('/broker', {factory: meshBroker, ping: ping});\n routes.set('/cluster', {factory: meshCluster, ping: ping});\n routes.set('/relays', {factory: meshRelays, ping: ping});\n }\n if (options.metrics) {\n middleware.push(...(await metrics(options.metrics)));\n }\n\n const ports = portRange(options.port ?? 0);\n const host = options.host;\n const serverP: Promise<http.Server> = new Promise((resolve, reject) => {\n const onSocketError = (err: Error) => logger.error(`socket error: ${err}`, err);\n const server = createServer(\n {},\n createListener(middleware, routes));\n\n server.on('error', (e: Error) => {\n if (e['code'] === 'EADDRINUSE') {\n logger.debug(`port ${e['port']} already in use on address ${e['address']}`);\n const {value: port} = ports.next();\n if (port) {\n logger.info(`retry starting server on port ${port} and host ${host ?? '<unspecified>'}`);\n server.close();\n server.listen(port, host);\n } else {\n logger.warn(`all configured port(s) ${options.port} are in use. closing...`);\n server.close();\n reject(e);\n }\n } else {\n logger.error(`server error: ${e.message}`, e);\n reject(e);\n }\n });\n server\n .on('listening', async () => {\n const info = server.address() as AddressInfo;\n for (const [path, route] of routes) {\n try {\n logger.info(`creating ws server for [${path}]. max connections: ${route.maxConnections ?? '<unlimited>'}, origin filters: ${route.originFilters ? JSON.stringify(route.originFilters, regexAwareReplacer) : '<none>'}`);\n const wss = new WebSocketServer({noServer: true});\n const endpoint = `${ssl ? 'wss' : 'ws'}://${localIp}:${info.port}${path}`;\n const handler = await route.factory({endpoint, wss});\n const pingInterval = route.ping;\n if (pingInterval) {\n const pingIntervalId = setInterval(() => {\n for (const client of wss.clients) {\n if (client['connected'] === false) {\n client.terminate();\n }\n client['connected'] = false;\n client.ping();\n }\n }, pingInterval);\n wss.on('close', () => {\n clearInterval(pingIntervalId);\n });\n }\n route.wss = wss;\n route.close = handler.close?.bind(handler);\n } catch (e) {\n logger.warn(`failed to init route ${path}`, e);\n }\n }\n logger.info(`http server listening on ${info.address}:${info.port}`);\n resolve(server);\n });\n server\n .on('upgrade', (req, socket, head) => {\n socket.addListener('error', onSocketError);\n try {\n const request = new HttpServerRequest(req);\n const path = request.path ?? '/';\n const route = (routes.get(path) ?? Array.from(routes.values()).find(route => {\n if (path === '/' && route.default === true) {\n return true;\n }\n }));\n const host = request.host;\n const info = socketKey(request.socket);\n if (route?.wss) {\n socket.removeListener('error', onSocketError);\n const wss = route.wss;\n if (route.maxConnections !== undefined && wss.clients?.size >= route.maxConnections) {\n logger.warn(`${info} dropping ws connection request from ${host} on ${path}. max connections exceeded.`);\n socket.destroy();\n return;\n }\n\n const origin = request.headers['origin'];\n if (!acceptsOrigin(origin, route.originFilters)) {\n logger.info(`${info} dropping ws connection request from ${host} on ${path}. origin ${origin ?? '<missing>'}`);\n socket.destroy();\n return;\n }\n if (logger.enabledFor('debug')) {\n logger.debug(`${info} accepted new ws connection request from ${host} on ${path}`);\n }\n\n wss.handleUpgrade(req, socket, head, (ws) => {\n ws.on('pong', () => ws['connected'] = true);\n ws.on('ping', () => {\n });\n wss.emit('connection', ws, req);\n });\n } else {\n logger.warn(`${info} rejected upgrade request from ${host} on ${path}`);\n socket.destroy();\n }\n } catch (err) {\n logger.error(`upgrade error: ${err}`, err);\n }\n })\n .on('close', async () => {\n logger.info(`http server closed.`);\n });\n try {\n const {value: port} = ports.next();\n server.listen(port, host);\n } catch (e) {\n logger.error(`error starting web socket server`, e);\n reject(e instanceof Error ? e : new Error(`listen failed: ${e}`));\n }\n });\n const server = await serverP;\n return new class implements GatewayServer.Server {\n readonly gateway = gw;\n\n async close(): Promise<void> {\n for (const [path, route] of routes) {\n try {\n if (route.close) {\n await route.close();\n }\n logger.info(`stopping ws server for [${path}]. clients: ${route.wss?.clients?.size ?? 0}`);\n route.wss?.clients?.forEach(client => {\n client.terminate();\n });\n route.wss?.close();\n } catch (e) {\n logger.warn(`error closing route ${path}`, e);\n }\n }\n await promisify(cb => {\n server.closeAllConnections();\n server.close(cb);\n });\n if (monitor) {\n await monitoring.stop(monitor);\n }\n }\n }\n\n}\n", "import {IOGateway} from '@interopio/gateway';\nexport import Logger = IOGateway.Logging.Logger;\n\nexport default function getLogger(name: string): Logger {\n return IOGateway.Logging.getLogger(`gateway.server.${name}`);\n}\n", "import {type Socket} from 'node:net';\n\nexport function socketKey(socket: Socket): string {\n const remoteIp = socket.remoteAddress;\n if (!remoteIp) {\n throw new Error('Socket has no remote address');\n }\n return `${remoteIp}:${socket.remotePort}`;\n}\n\n", "export type Middleware<Request extends ServerHttpRequest = ServerHttpRequest, Response extends ServerHttpResponse = ServerHttpResponse> = ((context: WebExchange<Request, Response>, next: () => Promise<void>) => Promise<void>)[];\n\nexport abstract class WebExchange<Request extends ServerHttpRequest = ServerHttpRequest, Response extends ServerHttpResponse = ServerHttpResponse> {\n abstract readonly request: Request\n abstract readonly response: Response\n\n get method(): string | undefined {\n return this.request.method;\n }\n\n get path(): string | null | undefined {\n return this.request.path;\n }\n}\nexport type ReadonlyHeaderValue = string | undefined;\nexport type HeaderValue = number | ReadonlyHeaderValue;\nexport type ReadonlyHeaderValues = (readonly string[]) | ReadonlyHeaderValue\nexport type HeaderValues = (readonly string[]) | HeaderValue\n\nexport interface HttpHeaders {\n get(name: string): HeaderValues\n list(name: string): string[]\n one(name: string): HeaderValue\n has(name: string): boolean\n keys(): IteratorObject<string>\n}\n\nexport interface ReadonlyHttpHeaders extends HttpHeaders {\n get(name: string): ReadonlyHeaderValues\n one(name: string): string | undefined\n\n}\nexport interface MutableHttpHeaders extends HttpHeaders {\n get(name: string): HeaderValues\n one(name: string): HeaderValue\n set(name: string, value: HeaderValues): this\n add(name: string, value: string | (readonly string[])): this\n}\n\nexport interface HttpMessage<Headers = HttpHeaders> {\n readonly headers: Headers\n}\n\nexport interface HttpRequest<Headers = HttpHeaders> extends HttpMessage<Headers> {\n readonly path: string,\n readonly method?: string\n readonly URL: URL\n readonly protocol: string\n /**\n * hostname[:port]\n */\n readonly host?: string\n readonly body: Promise<Blob>\n}\n\nexport interface HttpResponse<Headers = HttpHeaders> extends HttpMessage<Headers> {\n readonly statusCode: number\n}\n\nexport type ServerHttpRequest = HttpRequest<ReadonlyHttpHeaders> /* & {\n // readonly text: Promise<string>\n // readonly json: Promise<unknown>\n // readonly socket: http.IncomingMessage['socket']\n // readonly _req: http.IncomingMessage\n}*/\n\nexport interface ServerHttpResponse extends HttpResponse<MutableHttpHeaders> {\n statusCode: number\n}\n", "import {\n HeaderValue,\n HeaderValues,\n HttpRequest, HttpResponse,\n MutableHttpHeaders,\n ReadonlyHttpHeaders,\n ServerHttpRequest,\n ServerHttpResponse,\n WebExchange\n} from './types.js';\nimport http from 'node:http';\nimport http2 from 'node:http2';\n\nfunction requestToProtocol(request: ServerHttpRequest, defaultProtocol: string): string {\n let proto = request.headers.get('x-forwarded-proto');\n\n if (Array.isArray(proto)) {\n proto = proto[0];\n }\n if (proto !== undefined) {\n return (proto as string).split(',', 1)[0].trim();\n }\n return defaultProtocol;\n}\n\nfunction requestToHost(request: ServerHttpRequest, defaultHost?: string): string | undefined {\n let host = request.headers.get('x-forwarded-for');\n if (host === undefined) {\n host = request.headers.get('x-forwarded-host');\n if (Array.isArray(host)) {\n host = host[0];\n }\n if (host) {\n const port = request.headers.one('x-forwarded-port');\n if (port) {\n host = `${host}:${port}`;\n }\n }\n if (host === undefined) {\n host = request.headers.one('host');\n }\n }\n if (Array.isArray(host)) {\n host = host[0];\n }\n if (host) {\n return (host as string).split(',', 1)[0].trim();\n }\n\n\n\n return defaultHost;\n}\n\nexport class HttpServerRequest implements ServerHttpRequest {\n private _body: Promise<Blob> | undefined;\n private _url: URL | undefined;\n private readonly _headers: ReadonlyHttpHeaders;\n constructor(readonly _req: http.IncomingMessage) {\n this._headers = new IncomingMessageHeaders(_req);\n }\n\n get http2(): boolean {\n return this._req.httpVersionMajor >= 2;\n }\n\n get headers() {\n return this._headers;\n }\n\n get path() {\n return this.URL?.pathname;\n }\n\n get URL() {\n this._url ??= new URL(this._req.url!, `${this.protocol}://${this.host}`);\n return this._url;\n }\n get query() {\n return this.URL?.search;\n }\n\n get method() {\n return this._req.method;\n }\n\n get host() {\n let dh: string | undefined = undefined;\n if (this._req.httpVersionMajor >= 2) {\n dh = (this._req?.headers as http2.IncomingHttpHeaders)[':authority'];\n }\n if (dh === undefined) {\n dh = this._req?.socket.remoteAddress;\n }\n return requestToHost(this, dh);\n }\n\n get protocol() {\n let dp: string | undefined = undefined;\n if (this._req.httpVersionMajor > 2) {\n dp = (this._req.headers as http2.IncomingHttpHeaders)[':scheme'];\n }\n if (dp === undefined) {\n dp = this._req?.socket['encrypted'] ? 'https' : 'http'\n }\n return requestToProtocol(this, dp);\n }\n\n get socket() {\n return this._req.socket;\n }\n\n get body() {\n this._body ??= new Promise((resolve, reject) => {\n const chunks: Uint8Array[] = [];\n this._req\n .on('error', (err: Error) => reject(err))\n .on('data', (chunk) => chunks.push(chunk))\n .on('end', () => {\n resolve(new Blob(chunks));\n });\n });\n return this._body;\n }\n\n get text() {\n return this.body.then(async (blob) => await blob.text());\n }\n\n get json() {\n return this.body.then(async (blob) => {\n const json = JSON.parse(await blob.text());\n return json;\n // try {\n // } catch (e) {\n // reject(e instanceof Error ? e : new Error(`parse failed :${e}`));\n // }\n });\n }\n}\n\nclass IncomingMessageHeaders implements ReadonlyHttpHeaders {\n constructor(private readonly _msg: http.IncomingMessage) {\n }\n\n has(name: string): boolean {\n return this._msg.headers[name] !== undefined;\n }\n\n get(name: string): string | (readonly string[]) | undefined {\n return this._msg.headers[name];\n }\n\n list(name: string): string[] {\n return toList(this._msg.headers[name]);\n }\n\n one(name: string): string | undefined {\n const value = this._msg.headers[name];\n if (Array.isArray(value)) {\n return value[0];\n }\n return value;\n }\n keys() {\n return Object.keys(this._msg.headers).values();\n }\n}\n\nclass OutgoingMessageHeaders implements MutableHttpHeaders {\n constructor(private readonly _msg: http.OutgoingMessage) {\n }\n\n has(name: string): boolean {\n return this._msg.hasHeader(name);\n }\n\n keys() {\n return this._msg.getHeaderNames().values();\n }\n\n get(name: string): HeaderValues {\n return this._msg.getHeader(name);\n }\n\n one(name: string): HeaderValue {\n const value = this._msg.getHeader(name);\n if (Array.isArray(value)) {\n return value[0];\n }\n return value;\n }\n\n set(name: string, value: HeaderValues): this {\n if (!this._msg.headersSent) {\n if (Array.isArray(value)) {\n value = value.map(v => typeof v === 'number' ? String(v) : v);\n } else if (typeof value === 'number') {\n value = String(value);\n }\n\n if (value) {\n this._msg.setHeader(name, value);\n }\n else {\n this._msg.removeHeader(name);\n }\n }\n return this;\n }\n\n add(name: string, value: string | (readonly string[])): this {\n if (!this._msg.headersSent) {\n this._msg.appendHeader(name, value);\n }\n return this;\n }\n\n list(name: string): string[] {\n const values = this.get(name);\n return toList(values);\n }\n}\n\nexport class HttpServerResponse implements ServerHttpResponse {\n private readonly _headers: MutableHttpHeaders;\n constructor(readonly _res: http.ServerResponse) {\n this._headers = new OutgoingMessageHeaders(_res);\n }\n\n get statusCode(): number {\n return this._res.statusCode;\n }\n\n set statusCode(value: number) {\n if (this._res.headersSent) {\n return;\n }\n this._res.statusCode = value;\n }\n\n get headers() {\n return this._headers;\n }\n}\n\nexport class DefaultWebExchange<Request extends ServerHttpRequest, Response extends ServerHttpResponse> extends WebExchange<Request, Response> {\n constructor(readonly request: Request, readonly response: Response) {\n super();\n }\n}\n\nfunction toList(values: number | string | (readonly string[]) | undefined): string[] {\n if (typeof values === 'string') {\n values = [values];\n }\n if (typeof values === 'number') {\n values = [String(values)];\n }\n const list: string[] = [];\n if (values) {\n for (const value of values) {\n if (value) {\n list.push(...parseHeader(value));\n }\n }\n }\n return list;\n}\n\nfunction parseHeader(value: string): string[] {\n const list: string[] = [];\n {\n let start = 0;\n let end = 0;\n\n for (let i = 0; i < value.length; i++) {\n switch (value.charCodeAt(i)) {\n case 0x20: // space\n if (start === end) {\n start = end = i + 1;\n }\n break;\n case 0x2c: // comma\n list.push(value.slice(start, end));\n start = end = i + 1;\n break;\n default:\n end = end + 1;\n break;\n }\n }\n list.push(value.slice(start, end));\n }\n\n return list;\n}\n\nexport class MapHttpHeaders extends Map<string, (readonly string[])> implements MutableHttpHeaders {\n\n get(name: string) {\n return super.get(name.toLowerCase());\n }\n\n one(name: string): string | undefined {\n return this.get(name)?.[0];\n }\n\n list(name: string) {\n const values = super.get(name.toLowerCase());\n return toList(values);\n }\n\n set(name: string, value: number | string | (readonly string[]) | undefined): this {\n if (typeof value === 'number') {\n value = String(value);\n }\n if (typeof value === 'string') {\n value = [value];\n }\n if (value) {\n return super.set(name.toLowerCase(), value);\n }\n else {\n super.delete(name.toLowerCase());\n return this;\n }\n }\n\n add(name: string, value: string | (readonly string[])) {\n const prev = super.get(name.toLowerCase());\n if (typeof value === 'string') {\n value = [value];\n }\n if (prev) {\n value = prev.concat(value);\n }\n this.set(name, value);\n return this;\n }\n}\n\nexport class MockHttpRequest implements HttpRequest<MutableHttpHeaders> {\n private _body?: Blob;\n readonly headers = new MapHttpHeaders();\n\n constructor(private readonly url: URL, method?: string) {\n this.method = method ?? 'GET';\n this.headers.set('Host', url.hostname);\n this.path = url?.pathname ?? '/';\n }\n method: string;\n path: string;\n\n get host() {\n return requestToHost(this, this.url.host);\n }\n get protocol() {\n return requestToProtocol(this, this.url.protocol.slice(0, -1));\n }\n\n get body(): Promise<Blob> {\n const body = this._body;\n return body ? Promise.resolve(body): Promise.reject(new Error(`no body set`));\n }\n set body(value: Blob) {\n this._body = value;\n }\n\n get URL() {\n return new URL(this.path, `${this.protocol}://${this.host}`);\n }\n}\n\nexport class MockHttpResponse implements HttpResponse<MutableHttpHeaders> {\n statusCode!: number;\n headers = new MapHttpHeaders();\n\n}\n", "import * as ws from 'ws';\nimport getLogger from '../../logger.js';\nimport {socketKey} from '../../utils.js';\nimport {HttpServerRequest} from '../../server/exchange.js';\nimport {IOGateway} from '@interopio/gateway';\nimport GatewayEncoders = IOGateway.Encoding;\n\nconst log = getLogger('ws');\nconst codec = GatewayEncoders.json<IOGateway.Message>();\n\nfunction initClient(this: IOGateway.Gateway, key: string, socket: ws.WebSocket, host?: string): IOGateway.GatewayClient<string> | undefined {\n const opts = {\n key,\n host,\n codec,\n onPing: () => {\n socket.ping((err?: Error) => {\n if (err) {\n log.warn(`failed to ping ${key}`, err);\n }\n else {\n log.info(`ping sent to ${key}`);\n }\n });\n },\n onDisconnect: (reason: 'inactive' | 'shutdown') => {\n switch (reason) {\n case 'inactive': {\n log.warn(`no heartbeat (ping) received from ${key}, closing socket`);\n socket.close(4001, 'ping expected');\n break;\n }\n case 'shutdown': {\n socket.close(1001, 'shutdown');\n break;\n }\n }\n }\n };\n try {\n return this.client((data) => socket.send(data), opts);\n } catch (err) {\n log.warn(`${key} failed to create client`, err);\n }\n}\n\nasync function create(this: IOGateway.Gateway, server: {endpoint: string, wss: ws.WebSocketServer}): Promise<{ close: () => Promise<void> }> {\n log.info('start gateway');\n await this.start({endpoint: server.endpoint});\n\n server.wss\n .on('error', (err: Error) => {\n log.error('error starting the gateway websocket server', err);\n })\n .on('connection', (socket, req) => {\n const request = new HttpServerRequest(req);\n const key = socketKey(request.socket);\n const host = request.host;\n log.info(`${key} connected on gw from ${host}`);\n const client = initClient.call(this, key, socket);\n if (!client) {\n log.error(`${key} gw client init failed`);\n socket.terminate();\n return;\n }\n socket.on('error', (err: Error) => {\n log.error(`${key} websocket error: ${err}`, err);\n });\n socket.on('message', (data, _isBinary) => {\n if (Array.isArray(data)) {\n data = Buffer.concat(data);\n }\n // JSON.parse will invoke abstract operation ToString (coerce it) prior parsing (https://262.ecma-international.org/5.1/#sec-15.12.2)\n client.send(data as unknown as string);\n\n });\n socket.on('close', (code) => {\n log.info(`${key} disconnected from gw. code: ${code}`);\n client.close();\n });\n });\n return {\n close: async () => {\n server.wss.close();\n await this.stop();\n }\n }\n}\n\nexport default create;\n", "import getLogger from '../logger.js';\r\n\r\nconst logger = getLogger('mesh.connections');\r\n\r\nexport type Node = { node: string, endpoint: string, users?: string[] };\r\nexport type NodeConnection = { node: string, connect: Node[] }\r\n\r\ntype NodeValue = Omit<Node, 'users'> & {\r\n users: Set<string>, memberId: number, lastAccess: number\r\n}\r\n\r\nexport interface NodeConnections {\r\n announce(nodes: Node[]): NodeConnection[];\r\n remove(node: string): boolean;\r\n}\r\n\r\nexport default class InMemoryNodeConnections implements NodeConnections {\r\n private readonly nodes = new Map<string, NodeValue>();\r\n private readonly nodesByEndpoint = new Map<string, string>();\r\n private memberIds: number = 0;\r\n\r\n constructor(private readonly timeout: number = 60000) {\r\n }\r\n\r\n announce(nodes: Node[]): NodeConnection[] {\r\n for (const node of nodes) {\r\n const {node: nodeId, users, endpoint} = node;\r\n const foundId = this.nodesByEndpoint.get(endpoint);\r\n if (foundId) {\r\n if (foundId !== nodeId) {\r\n logger.warn(`endpoint ${endpoint} clash. replacing node ${foundId} with ${nodeId}`);\r\n this.nodesByEndpoint.set(endpoint, nodeId);\r\n this.nodes.delete(foundId);\r\n }\r\n } else {\r\n logger.info(`endpoint ${endpoint} announced for ${nodeId}`);\r\n this.nodesByEndpoint.set(endpoint, nodeId);\r\n }\r\n this.nodes.set(nodeId, this.updateNode(node, new Set<string>(users ?? []), nodeId, this.nodes.get(nodeId)));\r\n }\r\n this.cleanupOldNodes();\r\n const sortedNodes = Array.from(this.nodes.values()).sort((a, b) => a.memberId - b.memberId);\r\n return nodes.map((e) => {\r\n const node = e.node;\r\n const connect = this.findConnections(sortedNodes, this.nodes.get(node));\r\n return {node, connect};\r\n });\r\n }\r\n\r\n remove(nodeId: string) {\r\n const removed = this.nodes.get(nodeId);\r\n if (removed) {\r\n this.nodes.delete(nodeId);\r\n const endpoint = removed.endpoint;\r\n this.nodesByEndpoint.delete(endpoint);\r\n logger.info(`endpoint ${endpoint} removed for ${nodeId}`);\r\n return true;\r\n }\r\n return false;\r\n }\r\n\r\n private updateNode(newNode: Node, users: Set<string>, _key: string, oldNode?: NodeValue): NodeValue {\r\n const node: Omit<NodeValue, 'lastAccess' | 'users'> = !oldNode ? {...newNode, memberId: this.memberIds++} : oldNode;\r\n return {...node, users, lastAccess: Date.now()};\r\n }\r\n\r\n private cleanupOldNodes() {\r\n const threshold = Date.now() - this.timeout;\r\n for (const [nodeId,v] of this.nodes) {\r\n if (v.lastAccess < threshold) {\r\n if (logger.enabledFor('debug')) {\r\n logger.debug(`${nodeId} expired - no announcement since ${new Date(v.lastAccess).toISOString()}, timeout is ${this.timeout} ms.`);\r\n }\r\n this.nodes.delete(nodeId);\r\n }\r\n }\r\n }\r\n\r\n private findConnections(sortedNodes: NodeValue[], node?: NodeValue): Node[] {\r\n return sortedNodes.reduce((l, c) => {\r\n if (node !== undefined && c.memberId < node.memberId) {\r\n const intersection = new Set(c.users);\r\n node.users.forEach(user => {\r\n if (!c.users.has(user)) {\r\n intersection.delete(user);\r\n }\r\n });\r\n c.users.forEach(user => {\r\n if (!node.users.has(user)) {\r\n intersection.delete(user);\r\n }\r\n });\r\n if (intersection.size > 0) {\r\n const e: Node = {node: c.node, endpoint: c.endpoint};\r\n return l.concat(e);\r\n }\r\n }\r\n return l;\r\n }, new Array<Node>());\r\n }\r\n}\r\n", "import {Middleware} from '../../server/types.js';\r\nimport {NodeConnections, Node} from '../connections.js';\r\nimport {HttpServerRequest, HttpServerResponse} from '../../server/exchange.js';\r\n\r\nfunction routes(connections: NodeConnections): Middleware<HttpServerRequest, HttpServerResponse> {\r\n return [\r\n async (ctx, next: () => Promise<void>) => {\r\n if (ctx.method === 'POST' && ctx.path === '/api/nodes') {\r\n const json = await ctx.request.json;\r\n if (!Array.isArray(json)) {\r\n ctx.response.statusCode = 400\r\n ctx.response._res.end();\r\n } else {\r\n const nodes = json as Node[];\r\n const result = connections.announce(nodes);\r\n const body = JSON.stringify(result);\r\n ctx.response.headers.set('content-type', 'application/json');\r\n ctx.response.statusCode = 200;\r\n ctx.response._res\r\n .end(body);\r\n }\r\n } else {\r\n await next();\r\n }\r\n },\r\n async ({method, path, response}, next: () => Promise<void>) => {\r\n if (method === 'DELETE' && path?.startsWith('/api/nodes/')) {\r\n const nodeId = path?.substring('/api/nodes/'.length);\r\n connections.remove(nodeId);\r\n response.statusCode = 200;\r\n response._res.end();\r\n } else {\r\n await next();\r\n }\r\n },\r\n ];\r\n}\r\nexport default routes;\r\n", "import * as ws from 'ws';\nimport getLogger from '../../../logger.js';\nimport {socketKey} from '../../../utils.ts';\nimport {IOGateway} from '@interopio/gateway';\nimport GatewayEncoders = IOGateway.Encoding;\n\nconst logger = getLogger('mesh.ws.broker');\n\ntype Command =\n // client to broker\n { type: 'hello', 'node-id': string }\n | { type: 'bye', 'node-id': string }\n | { type: 'data', from: string, to: 'all' | string}\n // broker to client\n | { type: 'node-added', 'node-id': string, 'new-node': string }\n | { type: 'node-removed', 'node-id': string, 'removed-node': string }\n\n ;\n\n\nfunction broadcastNodeAdded(nodes: NodeSockets, newSocket: ws.WebSocket, newNodeId: string) {\n Object.entries(nodes.nodes).forEach(([nodeId, socket]) => {\n if (nodeId !== newNodeId) {\n newSocket.send(codec.encode({type: 'node-added', 'node-id': newNodeId, \"new-node\": nodeId}));\n socket.send(codec.encode({type: 'node-added', 'node-id': nodeId, \"new-node\": newNodeId}));\n }\n });\n}\n\nfunction broadcastNodeRemoved(nodes: NodeSockets, removedNodeId: string) {\n Object.entries(nodes.nodes).forEach(([nodeId, socket]) => {\n if (nodeId !== removedNodeId) {\n socket.send(codec.encode({type: 'node-removed', 'node-id': nodeId, \"removed-node\": removedNodeId}));\n }\n });\n}\n\nfunction onOpen(connectedNodes: NodeSockets, key: string) {\n logger.info(`[${key}] connection accepted`);\n}\n\nfunction onClose(connectedNodes: NodeSockets, key: string, code: number, reason: string): void {\n logger.info(`[${key}] connected closed [${code}](${reason})`);\n const nodeIds = connectedNodes.sockets[key];\n if (nodeIds) {\n delete connectedNodes.sockets[key];\n for (const nodeId of nodeIds) {\n delete connectedNodes.nodes[nodeId];\n }\n for (const nodeId of nodeIds) {\n broadcastNodeRemoved(connectedNodes, nodeId);\n }\n }\n}\n\nfunction processMessage(connectedNodes: NodeSockets, socket: ws.WebSocket, key: string, msg: Command) {\n switch (msg.type) {\n case 'hello': {\n const nodeId = msg['node-id'];\n connectedNodes.nodes[nodeId] = socket;\n connectedNodes.sockets[key] = connectedNodes.sockets[key] ?? [];\n connectedNodes.sockets[key].push(nodeId);\n logger.info(`[${key}] node ${nodeId} added.`);\n broadcastNodeAdded(connectedNodes, socket, nodeId);\n break;\n }\n case 'bye': {\n const nodeId = msg[\"node-id\"];\n delete connectedNodes[nodeId];\n logger.info(`[${key}] node ${nodeId} removed.`);\n broadcastNodeRemoved(connectedNodes, nodeId);\n break;\n }\n case 'data': {\n const sourceNodeId = msg.from;\n const targetNodeId = msg.to;\n if ('all' === targetNodeId) {\n Object.entries(connectedNodes.nodes).forEach(([nodeId, socket]) => {\n if (nodeId !== sourceNodeId) {\n socket.send(codec.encode(msg));\n }\n });\n }\n else {\n const socket = connectedNodes.nodes[targetNodeId];\n if (socket) {\n socket.send(codec.encode(msg));\n }\n else {\n logger.warn(`unable to send to node ${targetNodeId} message ${JSON.stringify(msg)}`);\n }\n }\n break;\n }\n default: {\n logger.warn(`[${key}] ignoring unknown message ${JSON.stringify(msg)}`);\n break;\n }\n }\n}\n\nconst codec = GatewayEncoders.transit<Command>({\n keywordize: new Map<string, GatewayEncoders.KeywordizeCommand>([\n ['/type', '*'],\n ['/message/body/type', '*'],\n ['/message/origin', '*'],\n ['/message/receiver/type', '*'],\n ['/message/source/type', '*'],\n ['/message/body/type', '*'],\n ])\n});\n\nfunction onMessage(connectedNodes: NodeSockets, socket: ws.WebSocket, key: string, msg: string): void {\n try {\n const decoded = codec.decode(msg);\n if (logger.enabledFor('debug')) {\n logger.debug(`[${key}] processing msg ${JSON.stringify(decoded)}`);\n }\n processMessage(connectedNodes, socket, key, decoded);\n } catch (ex) {\n logger.error(`[${key}] unable to process message`, ex);\n }\n}\n\nclass WebsocketBroker {\n constructor(private readonly server: ws.WebSocketServer) {\n }\n\n async close() {\n this.server.close();\n }\n}\ntype NodeSockets = {nodes: {[nodeId: string]: ws.WebSocket}, sockets: {[socket: string]: string[]}};\n\nasync function create(server: {wss: ws.WebSocketServer}): Promise<{close: () => Promise<void>}> {\n const connectedNodes: NodeSockets = {nodes: {}, sockets: {}};\n logger.info(`mesh server is listening`);\n\n server.wss\n .on('error', () => {\n logger.error(`error starting mesh server`);\n })\n .on('connection', (socket, request) => {\n const key = socketKey(request.socket);\n onOpen(connectedNodes, key);\n socket.on('error', (err: Error) => {\n logger.error(`[${key}] websocket error: ${err}`, err);\n });\n socket.on('message', (data, isBinary) => {\n if (Array.isArray(data)) {\n data = Buffer.concat(data);\n }\n onMessage(connectedNodes, socket, key, data as unknown as string);\n });\n socket.on('close', (code, reason) => {\n onClose(connectedNodes, key, code, reason as unknown as string);\n });\n });\n\n return new WebsocketBroker(server.wss);\n}\n\nexport default create;\n", "import * as ws from 'ws';\nimport {socketKey} from '../../../utils.js';\nimport getLogger from '../../../logger.js';\n\nimport {IOGateway} from '@interopio/gateway';\nimport GatewayEncoders = IOGateway.Encoding;\n\nconst logger = getLogger('mesh.ws.relay');\n\ntype Command\n = { type: 'hello', 'from': string, to: string }\n | { type: 'bye', 'from': string, to: 'all' | string }\n | { type: 'data', from: string, to: 'all' | string, data: {body: {type: string}}}\n\n ;\n\nconst codec = GatewayEncoders.transit<Command>({\n keywordize: new Map<string, GatewayEncoders.KeywordizeCommand>([\n ['/type', '*'],\n ['/message/body/type', '*'],\n ['/message/origin', '*'],\n ['/message/receiver/type', '*'],\n ['/message/source/type', '*'],\n ['/message/body/type', '*'],\n ])\n});\n\nexport interface Relays {\n onMsg?: ((key: string, node: string, msg: string) => void);\n onErr?: ((key: string, err: Error) => void)\n send(key: string, node: string | undefined, msg: string, cb: (key: string, err?: Error) => void): void;\n}\n\nclass InternalRelays implements Relays {\n // key -> socket\n private readonly clients = new Map<string, ws.WebSocket>();\n // node -> key\n private readonly links = new Map<string, string>();\n public onMsg?: (key: string, node: string, msg: string) => void\n public onErr?: (key: string, err: Error) => void\n\n add(key: string, soc: ws.WebSocket) {\n this.clients.set(key, soc);\n }\n\n remove(key: string) {\n this.clients.delete(key);\n for (const [node, k] of this.links) {\n if (k === key) {\n this.links.delete(node);\n }\n }\n }\n\n receive(key: string, msg: string) {\n const node = this.link(key, msg);\n\n if (node && this.onMsg) {\n this.onMsg(key, node, msg);\n }\n }\n\n private link(key: string, msg: string): string | undefined {\n try {\n const decoded = codec.decode(msg);\n const {type, from, to} = decoded;\n if (to === 'all') {\n switch (type) {\n case 'hello': {\n if (logger.enabledFor('debug')) {\n logger.debug(`${key} registers node ${from}`);\n }\n this.links.set(from, key);\n break;\n }\n case 'bye': {\n if (logger.enabledFor('debug')) {\n logger.debug(`${key} unregisters node ${from}`);\n }\n this.links.delete(from);\n break;\n }\n }\n return;\n }\n return from;\n } catch (e) {\n if (this.onErr) {\n // probably a decode error or a bug\n this.onErr(key, e instanceof Error ? e : new Error(`link failed :${e}`));\n }\n else {\n logger.warn(`${key} unable to process ${msg}`, e);\n }\n }\n }\n\n send(key: string, node: string, msg: string, cb: (k: string, err?: Error) => void): void {\n const decoded = codec.decode(msg);\n if (logger.enabledFor('debug')) {\n logger.debug(`${key} sending msg to ${node} ${JSON.stringify(decoded)}`);\n }\n\n const clientKey = this.links.get(node);\n if (clientKey) {\n const client = this.clients.get(clientKey);\n if (client) {\n client.send(msg, {binary: false}, (err?: Error) => {\n cb(clientKey, err)\n });\n return;\n }\n }\n throw new Error(`${key} no active link for ${decoded.to}`);\n }\n}\nconst internal = new InternalRelays();\nexport const relays: Relays = internal;\n\n\nasync function create(server: {wss:ws.WebSocketServer}): Promise<{ close?: () => Promise<void> }> {\n\n logger.info(`relays server is listening`);\n server.wss\n .on('error', () => {\n logger.error(`error starting relays server`);\n })\n .on('connection', (socket, request) => {\n const key = socketKey(request.socket);\n logger.info(`${key} connected on relays`);\n internal.add(key, socket);\n //onOpen(connectedNodes, key);\n socket.on('error', (err: Error) => {\n logger.error(`[${key}] websocket error: ${err}`, err);\n });\n socket.on('message', (data, _isBinary) => {\n if (Array.isArray(data)) {\n data = Buffer.concat(data);\n }\n try {\n internal.receive(key, data as unknown as string);\n } catch (e) {\n logger.warn(`[${key}] error processing received data '${data}'`, e);\n }\n });\n socket.on('close', (code, reason) => {\n internal.remove(key);\n\n logger.info(`${key} disconnected from relays`);\n });\n });\n return {\n close: async () => {\n server.wss.close();\n }\n };\n}\n\nexport default create;\n", "import * as ws from 'ws';\nimport {socketKey} from '../../../utils.js';\nimport {relays} from '../relays/core.js';\nimport getLogger from '../../../logger.js';\nimport {HttpServerRequest} from '../../../server/exchange.ts';\n\nconst logger = getLogger('mesh.ws.cluster');\n\n\nfunction onMessage(key: string, node: string | undefined, socketsByNodeId: Map<string, Map<string, ws.WebSocket>>, msg: string) {\n try {\n relays.send(key, node, msg, (k, err?: Error) => {\n if (err) {\n logger.warn(`${k} error writing msg ${msg}: ${err}`);\n return;\n }\n if (logger.enabledFor('debug')) {\n logger.debug(`${k} sent msg ${msg}`);\n }\n });\n } catch (ex) {\n logger.error(`${key} unable to process message`, ex);\n if (node) {\n const socket = socketsByNodeId.get(node)?.get(key);\n socket?.terminate();\n }\n }\n}\n\nasync function create(server: {wss: ws.WebSocketServer}): Promise<{ close?: () => Promise<void> }> {\n const socketsByNodeId = new Map<string, Map<string, ws.WebSocket>>();\n relays.onMsg = (k, nodeId, msg) => {\n try {\n const sockets = socketsByNodeId.get(nodeId);\n if (sockets && sockets.size > 0) {\n for (const [key, socket] of sockets) {\n socket.send(msg, {binary: false}, (err?: Error) => {\n if (err) {\n logger.warn(`${key} error writing from ${k} msg ${msg}: ${err}`);\n return;\n