@interopio/gateway-server 0.4.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/changelog.md +94 -0
  2. package/dist/gateway-ent.cjs +305 -0
  3. package/dist/gateway-ent.cjs.map +7 -0
  4. package/dist/gateway-ent.js +277 -0
  5. package/dist/gateway-ent.js.map +7 -0
  6. package/dist/index.cjs +1713 -0
  7. package/dist/index.cjs.map +7 -0
  8. package/dist/index.js +1682 -0
  9. package/dist/index.js.map +7 -0
  10. package/dist/metrics-rest.cjs +21440 -0
  11. package/dist/metrics-rest.cjs.map +7 -0
  12. package/dist/metrics-rest.js +21430 -0
  13. package/dist/metrics-rest.js.map +7 -0
  14. package/gateway-server.d.ts +69 -0
  15. package/package.json +66 -0
  16. package/readme.md +9 -0
  17. package/src/common/compose.ts +40 -0
  18. package/src/gateway/ent/config.ts +174 -0
  19. package/src/gateway/ent/index.ts +18 -0
  20. package/src/gateway/ent/logging.ts +89 -0
  21. package/src/gateway/ent/server.ts +34 -0
  22. package/src/gateway/metrics/rest.ts +20 -0
  23. package/src/gateway/ws/core.ts +90 -0
  24. package/src/index.ts +3 -0
  25. package/src/logger.ts +6 -0
  26. package/src/mesh/connections.ts +101 -0
  27. package/src/mesh/rest-directory/routes.ts +38 -0
  28. package/src/mesh/ws/broker/core.ts +163 -0
  29. package/src/mesh/ws/cluster/core.ts +107 -0
  30. package/src/mesh/ws/relays/core.ts +159 -0
  31. package/src/metrics/routes.ts +86 -0
  32. package/src/server/address.ts +47 -0
  33. package/src/server/cors.ts +311 -0
  34. package/src/server/exchange.ts +379 -0
  35. package/src/server/monitoring.ts +167 -0
  36. package/src/server/types.ts +69 -0
  37. package/src/server/ws-client-verify.ts +79 -0
  38. package/src/server.ts +316 -0
  39. package/src/utils.ts +10 -0
  40. package/types/gateway-ent.d.ts +212 -0
package/src/server.ts ADDED
@@ -0,0 +1,316 @@
1
+ import {WebSocketServer} from 'ws';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import {SecureContextOptions} from 'node:tls';
5
+ import {AddressInfo} from 'node:net';
6
+ import {readFileSync} from 'node:fs';
7
+ import {AsyncLocalStorage} from 'node:async_hooks';
8
+ import {IOGateway} from '@interopio/gateway';
9
+ import wsGateway from './gateway/ws/core.js';
10
+ import NodeConnections from './mesh/connections.js';
11
+ import restDirectory from './mesh/rest-directory/routes.js';
12
+ import meshBroker from './mesh/ws/broker/core.js';
13
+ import meshRelays from './mesh/ws/relays/core.js';
14
+ import meshCluster from './mesh/ws/cluster/core.js';
15
+ import metrics from './metrics/routes.js';
16
+ import {compose} from './common/compose.js';
17
+ import {WebExchange, Middleware} from './server/types.js';
18
+ import {DefaultWebExchange, HttpServerRequest, HttpServerResponse} from './server/exchange.js';
19
+ import {socketKey} from './utils.js';
20
+ import getLogger from './logger.js';
21
+ import {localIp, portRange} from './server/address.js';
22
+ import * as monitoring from './server/monitoring.js';
23
+ import {acceptsOrigin, ProcessedOriginFilters, regexifyOriginFilters} from './server/ws-client-verify.js';
24
+ import {GatewayServer} from '../gateway-server';
25
+ import cors from './server/cors.ts';
26
+
27
+ const logger = getLogger('app');
28
+
29
+ function secureContextOptions(ssl: GatewayServer.SslConfig): SecureContextOptions {
30
+ const options: SecureContextOptions = {};
31
+ if (ssl.key) options.key = readFileSync(ssl.key);
32
+ if (ssl.cert) options.cert = readFileSync(ssl.cert);
33
+ if (ssl.ca) options.ca = readFileSync(ssl.ca);
34
+ return options;
35
+ }
36
+
37
+ type RequestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void;
38
+
39
+ function createListener(middleware: Middleware<HttpServerRequest, HttpServerResponse>,
40
+ routes: Map<string, RouteInfo>) {
41
+ const storage = new AsyncLocalStorage();
42
+
43
+ const listener = compose<WebExchange<HttpServerRequest, HttpServerResponse>>(
44
+ async ({response}, next) => {
45
+ response.headers.set('server', 'gateway-server');
46
+ await next();
47
+ },
48
+ ...cors({
49
+ origins: {allow: [/http:\/\/localhost(:\d+)?/]},
50
+ methods: {allow: ['GET', 'HEAD', 'POST', 'DELETE']},
51
+ headers: {allow: '*'},
52
+ credentials: {allow: true}
53
+ }),
54
+ ...middleware,
55
+ async ({request, response}, next) => {
56
+ if (request.method === 'GET' && request.path === '/health') {
57
+ response.statusCode = 200;
58
+ response._res.end(http.STATUS_CODES[200]);
59
+ } else {
60
+ await next();
61
+ }
62
+ },
63
+ async ({request, response}, next) => {
64
+ if (request.method === 'GET' && request.path === '/') {
65
+ response._res.end(`io.Gateway Server`);
66
+ } else {
67
+ await next();
68
+ }
69
+ },
70
+ async ({request, response}, _next) => {
71
+ const route = routes.get(request.path);
72
+ if (route) {
73
+ response.statusCode = 426;
74
+ response._res
75
+ .appendHeader('Upgrade', 'websocket')
76
+ .appendHeader('Connection', 'Upgrade')
77
+ .appendHeader('Content-Type', 'text/plain');
78
+ response._res.end(`This service [${request.path}] requires use of the websocket protocol.`);
79
+ } else {
80
+ response.statusCode = 404;
81
+ response._res.end(http.STATUS_CODES[404]);
82
+ }
83
+ }
84
+ );
85
+
86
+ return (request: http.IncomingMessage, response: http.ServerResponse) => {
87
+ const exchange = new DefaultWebExchange(new HttpServerRequest(request), new HttpServerResponse(response));
88
+ return storage.run(exchange, async () => {
89
+ if (logger.enabledFor('debug')) {
90
+ const socket = exchange.request._req.socket;
91
+ if (logger.enabledFor('debug')) {
92
+ logger.debug(`received ${exchange.method} request for ${exchange.path} from ${socket.remoteAddress}:${socket.remotePort}`);
93
+ }
94
+ }
95
+ return await listener(exchange);
96
+ });
97
+ };
98
+ }
99
+
100
+ function promisify<T>(fn: (callback?: (err?: Error) => void) => T): Promise<T> {
101
+ return new Promise<T>((resolve, reject) => {
102
+ const r = fn((err?: Error) => {
103
+ if (err) {
104
+ reject(err);
105
+ } else {
106
+ resolve(r);
107
+ }
108
+ });
109
+ });
110
+ }
111
+
112
+ type RouteInfo = {
113
+ readonly default?: boolean,
114
+ readonly ping?: number,
115
+ readonly maxConnections?: number
116
+ readonly originFilters?: ProcessedOriginFilters
117
+ readonly factory: (server: { endpoint: string, wss: WebSocketServer }) => Promise<{
118
+ info?: string,
119
+ close?: () => Promise<void>
120
+ }>,
121
+ // set later in listening
122
+ wss?: WebSocketServer,
123
+ close?: () => Promise<void>
124
+ }
125
+
126
+ function memoryMonitor(config?: GatewayServer.ServerConfig['memory']) {
127
+ if (config) {
128
+ return monitoring.start({
129
+ memoryLimit: config.memory_limit,
130
+ dumpLocation: config.dump_location,
131
+ dumpPrefix: config.dump_prefix,
132
+ reportInterval: config.report_interval,
133
+ maxBackups: config.max_backups
134
+ });
135
+ }
136
+ }
137
+
138
+ function regexAwareReplacer<T>(_key: string, value: T): string | T {
139
+ return value instanceof RegExp ? value.toString() : value;
140
+ }
141
+
142
+ export const Factory = async (options: GatewayServer.ServerConfig): Promise<GatewayServer.Server> => {
143
+ const ssl = options.ssl;
144
+ const createServer = ssl ? (options: http.ServerOptions, handler: RequestHandler) => https.createServer({...options, ...secureContextOptions(ssl)}, handler) : (options: http.ServerOptions, handler: RequestHandler) => http.createServer(options, handler);
145
+ const monitor = memoryMonitor(options.memory);
146
+ const middleware: Middleware<HttpServerRequest, HttpServerResponse> = [];
147
+ const routes: Map<string, RouteInfo> = new Map<string, RouteInfo>();
148
+ const gw = IOGateway.Factory({...options.gateway});
149
+ if (options.gateway) {
150
+ const config = options.gateway;
151
+ routes.set(config.route ?? '/', {
152
+ default: config.route === undefined,
153
+ ping: options.gateway.ping,
154
+ factory: wsGateway.bind(gw),
155
+ maxConnections: config.limits?.max_connections,
156
+ originFilters: regexifyOriginFilters(config.origins)
157
+ });
158
+ }
159
+ if (options.mesh) {
160
+ const connections = new NodeConnections(options.mesh.timeout ?? 60000);
161
+ middleware.push(...restDirectory(connections));
162
+ const ping = options.mesh.ping ?? 30000;
163
+ routes.set('/broker', {factory: meshBroker, ping: ping});
164
+ routes.set('/cluster', {factory: meshCluster, ping: ping});
165
+ routes.set('/relays', {factory: meshRelays, ping: ping});
166
+ }
167
+ if (options.metrics) {
168
+ middleware.push(...(await metrics(options.metrics)));
169
+ }
170
+
171
+ const ports = portRange(options.port ?? 0);
172
+ const host = options.host;
173
+ const serverP: Promise<http.Server> = new Promise((resolve, reject) => {
174
+ const onSocketError = (err: Error) => logger.error(`socket error: ${err}`, err);
175
+ const server = createServer(
176
+ {},
177
+ createListener(middleware, routes));
178
+
179
+ server.on('error', (e: Error) => {
180
+ if (e['code'] === 'EADDRINUSE') {
181
+ logger.debug(`port ${e['port']} already in use on address ${e['address']}`);
182
+ const {value: port} = ports.next();
183
+ if (port) {
184
+ logger.info(`retry starting server on port ${port} and host ${host ?? '<unspecified>'}`);
185
+ server.close();
186
+ server.listen(port, host);
187
+ } else {
188
+ logger.warn(`all configured port(s) ${options.port} are in use. closing...`);
189
+ server.close();
190
+ reject(e);
191
+ }
192
+ } else {
193
+ logger.error(`server error: ${e.message}`, e);
194
+ reject(e);
195
+ }
196
+ });
197
+ server
198
+ .on('listening', async () => {
199
+ const info = server.address() as AddressInfo;
200
+ for (const [path, route] of routes) {
201
+ try {
202
+ logger.info(`creating ws server for [${path}]. max connections: ${route.maxConnections ?? '<unlimited>'}, origin filters: ${route.originFilters ? JSON.stringify(route.originFilters, regexAwareReplacer) : '<none>'}`);
203
+ const wss = new WebSocketServer({noServer: true});
204
+ const endpoint = `${ssl ? 'wss' : 'ws'}://${localIp}:${info.port}${path}`;
205
+ const handler = await route.factory({endpoint, wss});
206
+ const pingInterval = route.ping;
207
+ if (pingInterval) {
208
+ const pingIntervalId = setInterval(() => {
209
+ for (const client of wss.clients) {
210
+ if (client['connected'] === false) {
211
+ client.terminate();
212
+ }
213
+ client['connected'] = false;
214
+ client.ping();
215
+ }
216
+ }, pingInterval);
217
+ wss.on('close', () => {
218
+ clearInterval(pingIntervalId);
219
+ });
220
+ }
221
+ route.wss = wss;
222
+ route.close = handler.close?.bind(handler);
223
+ } catch (e) {
224
+ logger.warn(`failed to init route ${path}`, e);
225
+ }
226
+ }
227
+ logger.info(`http server listening on ${info.address}:${info.port}`);
228
+ resolve(server);
229
+ });
230
+ server
231
+ .on('upgrade', (req, socket, head) => {
232
+ socket.addListener('error', onSocketError);
233
+ try {
234
+ const request = new HttpServerRequest(req);
235
+ const path = request.path ?? '/';
236
+ const route = (routes.get(path) ?? Array.from(routes.values()).find(route => {
237
+ if (path === '/' && route.default === true) {
238
+ return true;
239
+ }
240
+ }));
241
+ const host = request.host;
242
+ const info = socketKey(request.socket);
243
+ if (route?.wss) {
244
+ socket.removeListener('error', onSocketError);
245
+ const wss = route.wss;
246
+ if (route.maxConnections !== undefined && wss.clients?.size >= route.maxConnections) {
247
+ logger.warn(`${info} dropping ws connection request from ${host} on ${path}. max connections exceeded.`);
248
+ socket.destroy();
249
+ return;
250
+ }
251
+
252
+ const origin = request.headers['origin'];
253
+ if (!acceptsOrigin(origin, route.originFilters)) {
254
+ logger.info(`${info} dropping ws connection request from ${host} on ${path}. origin ${origin ?? '<missing>'}`);
255
+ socket.destroy();
256
+ return;
257
+ }
258
+ if (logger.enabledFor('debug')) {
259
+ logger.debug(`${info} accepted new ws connection request from ${host} on ${path}`);
260
+ }
261
+
262
+ wss.handleUpgrade(req, socket, head, (ws) => {
263
+ ws.on('pong', () => ws['connected'] = true);
264
+ ws.on('ping', () => {
265
+ });
266
+ wss.emit('connection', ws, req);
267
+ });
268
+ } else {
269
+ logger.warn(`${info} rejected upgrade request from ${host} on ${path}`);
270
+ socket.destroy();
271
+ }
272
+ } catch (err) {
273
+ logger.error(`upgrade error: ${err}`, err);
274
+ }
275
+ })
276
+ .on('close', async () => {
277
+ logger.info(`http server closed.`);
278
+ });
279
+ try {
280
+ const {value: port} = ports.next();
281
+ server.listen(port, host);
282
+ } catch (e) {
283
+ logger.error(`error starting web socket server`, e);
284
+ reject(e instanceof Error ? e : new Error(`listen failed: ${e}`));
285
+ }
286
+ });
287
+ const server = await serverP;
288
+ return new class implements GatewayServer.Server {
289
+ readonly gateway = gw;
290
+
291
+ async close(): Promise<void> {
292
+ for (const [path, route] of routes) {
293
+ try {
294
+ if (route.close) {
295
+ await route.close();
296
+ }
297
+ logger.info(`stopping ws server for [${path}]. clients: ${route.wss?.clients?.size ?? 0}`);
298
+ route.wss?.clients?.forEach(client => {
299
+ client.terminate();
300
+ });
301
+ route.wss?.close();
302
+ } catch (e) {
303
+ logger.warn(`error closing route ${path}`, e);
304
+ }
305
+ }
306
+ await promisify(cb => {
307
+ server.closeAllConnections();
308
+ server.close(cb);
309
+ });
310
+ if (monitor) {
311
+ await monitoring.stop(monitor);
312
+ }
313
+ }
314
+ }
315
+
316
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,10 @@
1
+ import {type Socket} from 'node:net';
2
+
3
+ export function socketKey(socket: Socket): string {
4
+ const remoteIp = socket.remoteAddress;
5
+ if (!remoteIp) {
6
+ throw new Error('Socket has no remote address');
7
+ }
8
+ return `${remoteIp}:${socket.remotePort}`;
9
+ }
10
+
@@ -0,0 +1,212 @@
1
+ type LogLevel
2
+ = "trace"
3
+ | "debug"
4
+ | "info"
5
+ | "warn"
6
+ | "error"
7
+ | "fatal"
8
+ | "report";
9
+
10
+ interface LogInfo {
11
+ time: Date;
12
+ level: LogLevel;
13
+ namespace?: string;
14
+ file?: string;
15
+ line?: number;
16
+ stacktrace?: Error
17
+ message: string
18
+ output: string;
19
+ }
20
+
21
+ export function configure_logging(config: { level: LogLevel, appender: (logInfo: LogInfo) => void, disabled_action_groups?: string[] }): void;
22
+
23
+ type AuthenticatorConfig = {
24
+ timeout?: number // defaults to 5000
25
+ "max-pending-requests"?: number // defaults to 20000
26
+ }
27
+ type AuthenticationRequest
28
+ = {method: 'secret', login: string, secret: string}
29
+ | {method: 'access-token', token: string};
30
+ type AuthenticationResponse
31
+ = {type: 'success', user: string, login?: string}
32
+ | {type: 'continue', authentication: {token?: string}}
33
+ | { type: 'failure', message?: string };
34
+ type AuthenticatorImpl = (request: AuthenticationRequest) => Promise<AuthenticationResponse>
35
+
36
+ type MetricsPublisherConfig = {
37
+ filters?: {
38
+ "non-matched": 'whitelist' | 'blacklist'
39
+ publishers: [{
40
+ publisher: Record<string, string | RegExp>,
41
+ metrics: {
42
+ blacklist?: (string | RegExp)[],
43
+ whitelist?: (string | RegExp)[]
44
+ }
45
+ }]
46
+ }
47
+ heartbeats?: number // defaults to 1000
48
+ buffer_size?: number; // defaults to 10000
49
+ conflation?: {
50
+ max_size?: number // defaults to 0
51
+ interval?: number // defaults to 1000
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Gateway Config
57
+ */
58
+ type GatewayConfig = {
59
+ ip?: string; // defaults to '0.0.0.0'
60
+ port?: number | string; // defaults to 3434
61
+ route?: string;
62
+ authentication?: {
63
+ token_ttl?: number
64
+ default?: string // defaults to 'basic'
65
+ available?: string[] // defaults to ['basic']
66
+ win?: AuthenticatorConfig
67
+ basic?: AuthenticatorConfig & {
68
+ ttl?: number
69
+ }
70
+ } | (Partial<Record<string, (AuthenticatorConfig & {authenticator: AuthenticatorImpl})>>);
71
+ metrics?: {
72
+ publishers?: ('file' | 'raw' | 'rest' | 'kafka' | {
73
+ startup?: () => Promise<unknown>,
74
+ function: (value: unknown) => Promise<void> | void,
75
+ cleanup?: (arg?: unknown) => Promise<void> | void,
76
+ context?: unknown,
77
+ configuration?: MetricsPublisherConfig & {
78
+ 'split-size'?: number // defaults to 1
79
+ }
80
+ })[];
81
+ file?: MetricsPublisherConfig & {
82
+ location: string;
83
+ append?: boolean; // defaults to false
84
+ 'skip-status'?: boolean // defaults to false
85
+ conflation: MetricsPublisherConfig['conflation'] & {
86
+ 'max-datapoints-repo': number // defaults to 50
87
+ }
88
+ };
89
+ raw?: {
90
+ location: string
91
+ }
92
+ rest?: MetricsPublisherConfig & {
93
+ endpoint: string;
94
+ authentication?: false | {
95
+ user?: string
96
+ password?: string,
97
+ path?: string
98
+ };
99
+ "user-agent"?: string // defaults to "metrics-rest/0.1.x"
100
+ headers?: { [key:string]: string };
101
+ timeout?: number; //defaults to 1000
102
+ conflation?: MetricsPublisherConfig['conflation'] & {
103
+ "max-datapoints-repo"?: number // defaults 50
104
+ };
105
+ client?: {
106
+ sspi?: boolean //
107
+ "max-in-flight"?: number //
108
+ "log-level"?: LogLevel // defaults to warn
109
+ }
110
+ };
111
+ kafka: {
112
+ topic: string;
113
+ "publisher-config": object;
114
+ conflation?: MetricsPublisherConfig['conflation'] & {
115
+ "max-datapoints-repo"?: number // defaults 50
116
+ };
117
+ }
118
+ filters?: MetricsPublisherConfig['filters']
119
+ identity?: {
120
+ system?: string // defaults to Glue
121
+ service?: string // defaults to Gateway
122
+ };
123
+ interop?: {
124
+ enabled?: boolean;
125
+ invoke: {
126
+ filters: {
127
+ methods: [{
128
+ name: (string | RegExp)
129
+ arguments: {
130
+ whitelist: (string | RegExp)[]
131
+ blacklist: (string | RegExp)[]
132
+ }
133
+ }]
134
+ "non-matched": 'whitelist' | 'blacklist'
135
+ }
136
+ }
137
+ }
138
+ }
139
+ cluster?: {
140
+ enabled?: boolean
141
+ configuration?: {
142
+ node_id?: string
143
+ },
144
+ type: 'p2p' | 'broker',
145
+ p2p?: {
146
+ directory: {
147
+ type: 'static' | 'rest',
148
+ members?: {node: string, endpoint: string}[],
149
+ config?: {
150
+ directory_uri: string,
151
+ announce_interval?: number, // defaults to 10000
152
+ authentication?: 'no-auth' | 'kerberos' | 'negotiate'
153
+ }
154
+ }
155
+ binding?: {
156
+ port?: number //defaults to 0
157
+ }
158
+ },
159
+ broker?: {
160
+ endpoint?: string
161
+ },
162
+ embedded_broker?: {
163
+ enabled?: boolean,
164
+ route?: string // defaults to /mesh-broker
165
+ }
166
+ }
167
+ limits?: {
168
+ max_connections?: number;
169
+ large_msg_threshold?: number; // 20000
170
+ node_buffer_size?: number; // 20000
171
+ }
172
+ security?: {
173
+ origin_filters?: {
174
+ non_matched: string
175
+ missing: string
176
+ blacklist?: []
177
+ whitelist?: []
178
+ }
179
+ }
180
+ memory?: {
181
+ memory_limit?: number, // defaults to 1073741824 bytes (1GB)
182
+ dump_location?: string, // defaults to current directory
183
+ dump_prefix?: string, // defaults to 'Heap'
184
+ report_interval?: number, // report schedule interval in ms. defaults to 600000 ms (10 min)
185
+ max_backups?: number, // defaults to 10
186
+ }
187
+ }
188
+
189
+ interface Gateway {
190
+ start(): Promise<Gateway>;
191
+
192
+ stop(): Promise<Gateway>;
193
+
194
+ info(): { endpoint: string };
195
+
196
+ connect(cb: (client: GatewayClient, msg: GatewayMessage) => void): Promise<GatewayClient>;
197
+ }
198
+
199
+ interface GatewayClient {
200
+ send(message: GatewayMessage): void;
201
+
202
+ disconnect(): Promise<boolean>;
203
+ }
204
+
205
+ interface GatewayMessage {
206
+ type: string
207
+ domain: string
208
+ destination: string
209
+ peer_id: string
210
+ }
211
+
212
+ export function create(config: GatewayConfig): Gateway;