geonix 1.23.8 → 1.30.2

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.
package/src/Gateway.js CHANGED
@@ -1,20 +1,36 @@
1
1
  import { connection } from "./Connection.js";
2
2
  import { registry } from "./Registry.js";
3
- import { cleanupWebsocketUrl, createTCPServer, GeonixVersion, picoid, proxyHttp, sleep } from "./Util.js";
3
+ import { cleanupWebsocketUrl, createTCPServer, GeonixVersion, hash, picoid, proxyHttp, sleep } from "./Util.js";
4
4
  import express, { Router } from "express";
5
5
  import { Request } from "./Request.js";
6
- import { HEALTH_CHECK_ENDPOINT } from "./WebServer.js";
7
6
  import expressWs from "express-ws";
8
- import querystring from "querystring";
9
7
  import semver from "semver";
10
8
  import { WebSocket } from "ws";
11
9
  import { logger } from "./Logger.js";
12
10
 
13
- const DEBUG_ENDPOINT = "/lZ6jD2eC3iP0zB3jJ1yJ9pM8gG3yI3vS";
14
11
  const endpointMatcher = /^((?<options>.+)\|)?(?<verb>WS|GET|POST|PATCH|PUT|DELETE|HEAD|OPTIONS|ALL)\s(?<url>.*)/;
15
12
 
13
+ const MAX_SESSIONS = 16384;
14
+
15
+ // Real client IP — checks well-known CDN/proxy headers before falling back to socket address
16
+ function getClientIp(req) {
17
+ const header =
18
+ req.headers["cf-connecting-ip"] || // Cloudflare
19
+ req.headers["true-client-ip"] || // Cloudflare Enterprise / Akamai
20
+ req.headers["x-real-ip"] || // nginx
21
+ req.headers["x-forwarded-for"]; // standard (may be comma-separated list)
22
+
23
+ if (header) { return header.split(",")[0].trim(); }
24
+ return req.socket?.remoteAddress || "unknown";
25
+ }
26
+
27
+ // Deterministic index into an array based on a string key
28
+ function stableIndex(str, length) {
29
+ return parseInt(hash(str).slice(0, 8), 16) % length;
30
+ }
31
+
16
32
  const requestLogger = (req, res, next) => {
17
- logger.info(`HTTP ${req.method} ${req.url}`);
33
+ logger.debug(`HTTP ${req.method} ${req.path}`);
18
34
 
19
35
  next();
20
36
  };
@@ -31,8 +47,19 @@ const defaultOpts = {
31
47
  afterRequest: (_req, _res) => { }
32
48
  };
33
49
 
50
+ /**
51
+ * HTTP gateway that automatically discovers Geonix services via the registry and reverse-proxies
52
+ * incoming HTTP and WebSocket requests to the appropriate service instance. Routes are rebuilt
53
+ * dynamically as services join and leave the bus.
54
+ */
34
55
  export class Gateway {
35
56
 
57
+ /**
58
+ * Creates and starts a new Gateway instance.
59
+ *
60
+ * @param {object} [opts] - Gateway options (e.g. `cors`, `beforeRequest`, `afterRequest`).
61
+ * @returns {Gateway}
62
+ */
36
63
  static start(opts) {
37
64
  return new Gateway(opts);
38
65
  }
@@ -46,16 +73,19 @@ export class Gateway {
46
73
 
47
74
  #rebuildRouter = false;
48
75
  #buildRouterRunning = false;
76
+ #rebuildRouterInterval = null;
49
77
  #endpoints = [];
50
78
 
51
79
  #registry = {};
80
+ #server;
81
+ #sessions = new Map(); // _gx token → backend address
52
82
 
53
83
  constructor(opts) {
54
84
  expressWs(this.#api);
55
85
 
56
86
  this.#opts = { ...this.#opts, ...opts };
57
87
 
58
- this.#start();
88
+ this.#start().catch(e => logger.error("gx.gateway.start:", e));
59
89
  }
60
90
 
61
91
  async #start(port = 8080) {
@@ -63,36 +93,59 @@ export class Gateway {
63
93
 
64
94
  await connection.waitUntilReady();
65
95
 
66
- this.#port = process.env.PORT || port;
67
- this.#api.listen(this.#port);
68
-
69
- logger.debug(`geonix.gateway: listening on http://0.0.0.0:${this.#port}`);
96
+ this.#port = process.env.GX_PORT || process.env.PORT || port;
70
97
 
71
98
  // logging
72
99
  this.#api.use(requestLogger);
73
100
 
74
101
  // cors
75
102
  this.#api.use((req, res, next) => {
103
+ const cors = this.#opts.cors;
76
104
  const origin = req.headers["origin"];
77
105
  const allMethods = "GET,PUT,POST,DELETE,OPTIONS,HEAD";
78
- const allHeaders = "*";
79
106
  const requestMethod = req.headers["access-control-request-method"];
80
107
  const requestHeaders = req.headers["access-control-request-headers"];
81
108
 
82
- res.set("Access-Control-Allow-Credentials", "true");
83
- res.set("Access-Control-Allow-Origin", origin || "*");
84
- res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
85
109
  res.set("Allow", requestMethod || allMethods);
86
- res.set("Access-Control-Allow-Headers", requestHeaders || allHeaders);
110
+
111
+ if (!cors) {
112
+ // off — no CORS headers
113
+ } else if (cors === "*") {
114
+ // wildcard — all origins, no credentials
115
+ res.set("Access-Control-Allow-Origin", "*");
116
+ res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
117
+ res.set("Access-Control-Allow-Headers", requestHeaders || "*");
118
+ } else if (Array.isArray(cors)) {
119
+ // allowlist — credentials only for listed origins
120
+ if (origin && cors.includes(origin)) {
121
+ res.set("Access-Control-Allow-Credentials", "true");
122
+ res.set("Access-Control-Allow-Origin", origin);
123
+ res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
124
+ res.set("Access-Control-Allow-Headers", requestHeaders || "*");
125
+ }
126
+ } else if (cors === true) {
127
+ // open — reflect any origin with credentials
128
+ if (origin) {
129
+ res.set("Access-Control-Allow-Credentials", "true");
130
+ res.set("Access-Control-Allow-Origin", origin);
131
+ res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
132
+ res.set("Access-Control-Allow-Headers", requestHeaders || "*");
133
+ }
134
+ }
87
135
 
88
136
  next();
89
137
  });
90
138
 
91
- // debug router (only available in non-production environments)
92
- if (process.env.NODE_ENV !== "production") {
93
- this.#api.use(DEBUG_ENDPOINT, this.#debugRouter());
139
+ // debug router (only mounted when GX_DEBUG_ENDPOINT is set)
140
+ if (process.env.GX_DEBUG_ENDPOINT) {
141
+ this.#api.use(process.env.GX_DEBUG_ENDPOINT, this.#debugRouter());
94
142
  }
95
143
 
144
+ // block internal gx namespace from being proxied to external clients
145
+ this.#api.all("/!!_gx/*", (req, res) => {
146
+ res.status(403).json({ error: 403, source: "gw" });
147
+ });
148
+
96
149
  this.#api.use((req, res, next) => {
97
150
  if (this.#opts.beforeRequest) {
98
151
  this.#opts.beforeRequest(req, res);
@@ -130,7 +183,10 @@ export class Gateway {
130
183
  });
131
184
  });
132
185
 
133
- setInterval(() => {
186
+ this.#server = this.#api.listen(this.#port);
187
+ logger.info(`geonix.gateway: listening on http://0.0.0.0:${this.#port}`);
188
+
189
+ this.#rebuildRouterInterval = setInterval(() => {
134
190
  if (this.#rebuildRouter) {
135
191
  this.#buildRouter();
136
192
  }
@@ -141,7 +197,7 @@ export class Gateway {
141
197
  // send keeplive to check if connection is still alive
142
198
  await connection.publish("gx.gateway.keepalive", Date.now());
143
199
 
144
- await this.#handleAddedServics();
200
+ await this.#handleAddedServices();
145
201
  await this.#handleRemovedServices();
146
202
  await sleep(1000);
147
203
 
@@ -153,42 +209,31 @@ export class Gateway {
153
209
  }
154
210
  }
155
211
 
156
- await this.#api.close();
212
+ this.#server?.close();
157
213
 
158
214
  // terminate process
159
- logger.debug("geonix.gateway: stopped");
215
+ logger.info("geonix.gateway: stopped");
160
216
  }
161
217
 
162
- async #handleAddedServics() {
218
+ async #handleAddedServices() {
163
219
  let entries = Object.values(registry.getEntries());
164
220
 
165
221
  const processEntry = async (entry) => {
166
- if (this.#registry[entry.i] !== undefined) { return false; }
222
+ const existing = this.#registry[entry.i];
167
223
 
168
- logger.info(`gateway.onServiceAdded: ${entry.n}@${entry.v} (#${entry.i})`);
169
-
170
- // figure out if endpoints is reachable via direct http call
171
- let backend;
172
- if (entry.a) {
173
- for (let address of entry.a) {
174
- try {
175
- const ac = new AbortController();
176
- const timeout = setTimeout(() => ac.abort(), 500);
177
- const result = await (await fetch(`http://${address}${HEALTH_CHECK_ENDPOINT}`, { signal: ac.signal })).json();
178
- clearTimeout(timeout);
179
- if (result.status === "healthy" && result.services?.includes(entry.n)) {
180
- backend = address;
181
- logger.info(`${entry.n}@${entry.v} (#${entry.i}) directly reachable @ ${address}`);
182
- break;
183
- }
184
- } catch {
185
- // silently ignore errors
186
- }
224
+ if (existing !== undefined) {
225
+ // trigger rebuild if a direct entry has gained new addresses since last seen
226
+ if (!existing.proxy && entry.a?.length !== existing.knownAddressCount) {
227
+ existing.knownAddressCount = entry.a.length;
228
+ this.#rebuildRouter = true;
187
229
  }
230
+ return false;
188
231
  }
189
232
 
233
+ logger.info(`gateway.onServiceAdded: ${entry.n}@${entry.v} (#${entry.i})`);
234
+
190
235
  let proxy;
191
- if (!backend) {
236
+ if (!entry.a?.length) {
192
237
  // create proxy over nats
193
238
  proxy = await createTCPServer(async (client) => {
194
239
  const streamId = picoid();
@@ -200,11 +245,10 @@ export class Gateway {
200
245
  logger.error("nats.proxy.error", e);
201
246
  client.destroy();
202
247
  }
203
- }, 50000, 10000);
204
- backend = `127.0.0.1:${proxy.port}`;
248
+ });
205
249
  }
206
250
 
207
- this.#registry[entry.i] = { entry, proxy, backend };
251
+ this.#registry[entry.i] = { entry, proxy, knownAddressCount: entry.a?.length ?? 0 };
208
252
 
209
253
  return true;
210
254
  };
@@ -270,7 +314,6 @@ export class Gateway {
270
314
  platform: process.platform,
271
315
  arch: process.arch
272
316
  },
273
- env: process.env,
274
317
  mem: process.memoryUsage(),
275
318
  rss: process.memoryUsage.rss(),
276
319
  cpu: process.cpuUsage()
@@ -293,15 +336,15 @@ export class Gateway {
293
336
  connection.unsubscribe(ingress);
294
337
  connection.publish(`gx2.stream.${streamId}.c`, Buffer.from("end"));
295
338
  });
296
- client.on("error", (_error) => {
297
- // silently ignore errors
339
+ client.on("error", (e) => {
340
+ logger.debug("nats.proxy.client.error:", e);
298
341
  });
299
342
 
300
343
  const dataLoop = async () => {
301
344
  for await (const event of ingress) { client.write(event.data); }
302
345
  };
303
346
 
304
- dataLoop();
347
+ dataLoop().catch(e => logger.error("nats.proxy.dataLoop:", e));
305
348
  }
306
349
  }
307
350
 
@@ -312,12 +355,13 @@ export class Gateway {
312
355
  * @param {Readable} inbound
313
356
  * @param {Request} req
314
357
  */
315
- #proxyWebsocketOverNats(target, inbound, req) {
358
+ #proxyWebsocket(target, inbound, req) {
316
359
  try {
317
- const backend = new WebSocket(target, {
318
- headers: {
319
- ...req.headers
320
- }
360
+ const backend = new WebSocket(target, { headers: { ...req.headers } });
361
+
362
+ backend.on("error", (e) => {
363
+ logger.error("proxy.ws.backend.error:", e);
364
+ inbound.close();
321
365
  });
322
366
 
323
367
  backend.on("open", () => {
@@ -328,7 +372,8 @@ export class Gateway {
328
372
  inbound.on("close", () => backend.close());
329
373
  });
330
374
  } catch (e) {
331
- logger.error(e);
375
+ logger.error("proxy.ws.backend.error:", e);
376
+ inbound.close();
332
377
  }
333
378
  }
334
379
 
@@ -348,14 +393,23 @@ export class Gateway {
348
393
 
349
394
  const endpoints = [];
350
395
 
351
- for (let { entry, backend } of entries) {
396
+ for (let { entry, proxy } of entries) {
397
+ const backendAddr = proxy
398
+ ? `127.0.0.1:${proxy.port}`
399
+ : entry.a?.[Math.floor(Math.random() * entry.a.length)];
400
+
401
+ if (!backendAddr) { continue; }
402
+
352
403
  // generate global endpoint list
353
404
  for (let e of entry.m) {
354
- if (endpointMatcher.test(e)) {
355
- const endpoint = endpointMatcher.exec(e).groups;
405
+ const endpointMatch = endpointMatcher.exec(e);
406
+ if (endpointMatch) {
407
+ const endpoint = endpointMatch.groups;
408
+ const parsed = endpoint.options ? Object.fromEntries(new URLSearchParams(endpoint.options)) : {};
409
+ const parsedOrder = parseInt(parsed.order, 10);
356
410
  let options = {
357
- order: 100,
358
- ...(endpoint.options ? querystring.parse(endpoint.options) : {})
411
+ ...parsed,
412
+ order: Number.isNaN(parsedOrder) ? 100 : parsedOrder
359
413
  };
360
414
 
361
415
  try {
@@ -364,7 +418,7 @@ export class Gateway {
364
418
  version: semver.coerce(entry.v).version,
365
419
  options,
366
420
  endpoint,
367
- backend: [backend]
421
+ backend: [backendAddr]
368
422
  });
369
423
  } catch (e) {
370
424
  logger.error("gateway.buildRouter.error:", entry);
@@ -391,28 +445,45 @@ export class Gateway {
391
445
 
392
446
  // sort endpoints by order, if there is one
393
447
  endpoints.sort((a, b) => semver.rcompare(a.version, b.version));
394
- endpoints.sort((a, b) => parseInt(a.options.order) - parseInt(b.options.order));
448
+ endpoints.sort((a, b) => a.options.order - b.options.order);
395
449
 
396
450
  this.#endpoints = endpoints;
397
451
 
398
452
  // build the router
399
453
  for (let endpoint of endpoints) {
400
454
  let { verb, url: uri } = endpoint.endpoint;
401
- let backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
402
-
403
455
  verb = verb.toLowerCase();
404
456
 
405
457
  if (verb === "ws") {
406
458
  router.ws(uri, (ws, req) => {
407
- let target = cleanupWebsocketUrl(`ws://${backend}${req.originalUrl}`);
459
+ const backends = endpoint.backend;
460
+ const gxToken = req.query?._gx;
461
+ let selectedBackend;
462
+
463
+ if (gxToken) {
464
+ const stored = this.#sessions.get(gxToken);
465
+ if (stored && backends.includes(stored)) {
466
+ selectedBackend = stored;
467
+ } else {
468
+ // backend gone or first time seeing this token
469
+ selectedBackend = backends[stableIndex(getClientIp(req), backends.length)];
470
+ if (this.#sessions.size >= MAX_SESSIONS) {
471
+ this.#sessions.delete(this.#sessions.keys().next().value);
472
+ }
473
+ this.#sessions.set(gxToken, selectedBackend);
474
+ }
475
+ } else {
476
+ selectedBackend = backends[stableIndex(getClientIp(req), backends.length)];
477
+ }
408
478
 
479
+ const target = cleanupWebsocketUrl(`ws://${selectedBackend}${req.originalUrl}`);
409
480
  logger.debug("proxy.web.ws.to:", target);
410
- this.#proxyWebsocketOverNats(target, ws, req);
481
+ this.#proxyWebsocket(target, ws, req);
411
482
  });
412
483
  } else {
413
484
  router[verb](uri, async (req, res, _next) => {
414
485
  stats.proxied++;
415
- backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
486
+ const backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
416
487
 
417
488
  try {
418
489
  logger.debug("proxy.web.to:", backend + req.originalUrl);
@@ -430,8 +501,13 @@ export class Gateway {
430
501
  }
431
502
  }
432
503
 
433
- async stop() {
504
+ /**
505
+ * Stops the gateway loop and closes the HTTP server.
506
+ * @returns {void}
507
+ */
508
+ stop() {
434
509
  this.#isActive = false;
510
+ clearInterval(this.#rebuildRouterInterval);
435
511
  }
436
512
 
437
513
  }
package/src/Logger.js CHANGED
@@ -1,34 +1,115 @@
1
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, none: 4 };
2
+ const TAGS = { debug: "DBG", info: "INF", warn: "WRN", error: "ERR" };
3
+
4
+ const LEVEL = LEVELS[process.env.GX_LOG_LEVEL] ?? LEVELS.info;
5
+ const FORMAT = process.env.GX_LOG_FORMAT === "json" ? "json" : "text";
6
+
1
7
  const defaultLoggerOptions = {
2
8
  timestamp: true
3
9
  };
4
10
 
11
+ function serialize(val) {
12
+ if (val instanceof Error) { return val.stack || val.message; }
13
+ if (typeof val === "object" && val !== null) { return JSON.stringify(val); }
14
+ return String(val);
15
+ }
16
+
17
+ /**
18
+ * Lightweight structured logger with configurable level filtering and output format.
19
+ * Each log method prefixes the message with an ISO timestamp (unless disabled) and a
20
+ * severity tag (`INF`, `WRN`, `ERR`, `DBG`). The active level is controlled by the
21
+ * `GX_LOG_LEVEL` environment variable (`debug` | `info` | `warn` | `error`).
22
+ * The output format is controlled by `GX_LOG_FORMAT` (`text` | `json`, default `text`).
23
+ */
5
24
  export class Logger {
6
25
 
7
26
  #options = defaultLoggerOptions;
27
+ #level = LEVEL;
28
+ #format = FORMAT;
8
29
 
30
+ /**
31
+ * @param {object} [options] - Logger options.
32
+ * @param {boolean} [options.timestamp=true] - Whether to prepend an ISO timestamp to each line (text format only).
33
+ * @param {'debug'|'info'|'warn'|'error'|'none'} [options.level] - Minimum level to emit. Use `none` to suppress all output. Overrides `GX_LOG_LEVEL`.
34
+ * @param {'text'|'json'} [options.format] - Output format. Overrides `GX_LOG_FORMAT`.
35
+ */
9
36
  constructor(options) {
10
37
  this.#options = { ...this.#options, ...options };
38
+ if (options?.level !== undefined) {
39
+ this.#level = LEVELS[options.level] ?? LEVEL;
40
+ }
41
+ if (options?.format !== undefined) {
42
+ this.#format = options.format === "json" ? "json" : "text";
43
+ }
11
44
  }
12
45
 
13
- #log(...args) {
14
- const ts = this.#options.timestamp ? new Date().toISOString() : undefined;
15
-
16
- // eslint-disable-next-line no-console
17
- console.log(...[ts, ...args].filter($ => $));
46
+ #log(level, ...args) {
47
+ const stream = level === "error" ? process.stderr : process.stdout;
48
+ if (this.#format === "json") {
49
+ stream.write(JSON.stringify({ time: new Date().toISOString(), level, msg: args.map(serialize).join(" ") }) + "\n");
50
+ } else {
51
+ const ts = this.#options.timestamp ? new Date().toISOString() : undefined;
52
+ stream.write([ts, TAGS[level], ...args].filter($ => $ !== undefined).map(serialize).join(" ") + "\n");
53
+ }
18
54
  }
19
55
 
56
+ /**
57
+ * Emits an informational log line.
58
+ * @param {...any} args - Values to log.
59
+ * @returns {void}
60
+ */
20
61
  info(...args) {
21
- this.#log("INF", ...args);
62
+ if (LEVELS.info >= this.#level) {
63
+ this.#log("info", ...args);
64
+ }
22
65
  }
23
66
 
67
+ /**
68
+ * Emits a warning log line.
69
+ * @param {...any} args - Values to log.
70
+ * @returns {void}
71
+ */
72
+ warn(...args) {
73
+ if (LEVELS.warn >= this.#level) {
74
+ this.#log("warn", ...args);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Emits an error log line.
80
+ * @param {...any} args - Values to log.
81
+ * @returns {void}
82
+ */
24
83
  error(...args) {
25
- this.#log("ERR", ...args);
84
+ if (LEVELS.error >= this.#level) {
85
+ this.#log("error", ...args);
86
+ }
26
87
  }
27
88
 
89
+ /**
90
+ * Emits a debug log line. Only shown when the active level is `debug`.
91
+ * @param {...any} args - Values to log.
92
+ * @returns {void}
93
+ */
28
94
  debug(...args) {
29
- this.#log("DBG", ...args);
95
+ if (LEVELS.debug >= this.#level) {
96
+ this.#log("debug", ...args);
97
+ }
98
+ }
99
+
100
+ setLevel(level) {
101
+ this.#level = LEVELS[level] ?? LEVEL;
102
+ }
103
+
104
+ setFormat(format) {
105
+ this.#format = format === "json" ? "json" : "text";
30
106
  }
31
107
 
32
108
  }
33
109
 
34
- export const logger = new Logger();
110
+ /**
111
+ * Default {@link Logger} instance used throughout the Geonix internals.
112
+ *
113
+ * @type {Logger}
114
+ */
115
+ export const logger = new Logger();