@interopio/gateway-server
Version:
## `@glue42/gateway-ent` compatibility
317 lines (299 loc) • 13.7 kB
text/typescript
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);
}
}
}
}