geonix 1.23.6 → 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,12 +47,25 @@ 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
  }
39
66
 
67
+ #isActive = false;
68
+
40
69
  #opts = defaultOpts;
41
70
  #api = express();
42
71
  #router = (req, res, next) => next();
@@ -44,51 +73,79 @@ export class Gateway {
44
73
 
45
74
  #rebuildRouter = false;
46
75
  #buildRouterRunning = false;
76
+ #rebuildRouterInterval = null;
47
77
  #endpoints = [];
48
78
 
49
79
  #registry = {};
80
+ #server;
81
+ #sessions = new Map(); // _gx token → backend address
50
82
 
51
83
  constructor(opts) {
52
84
  expressWs(this.#api);
53
85
 
54
86
  this.#opts = { ...this.#opts, ...opts };
55
87
 
56
- this.#start();
88
+ this.#start().catch(e => logger.error("gx.gateway.start:", e));
57
89
  }
58
90
 
59
91
  async #start(port = 8080) {
60
- await connection.waitUntilReady();
92
+ this.#isActive = true;
61
93
 
62
- this.#port = process.env.PORT || port;
63
- this.#api.listen(this.#port);
94
+ await connection.waitUntilReady();
64
95
 
65
- logger.debug(`geonix.gateway: listening on http://0.0.0.0:${this.#port}`);
96
+ this.#port = process.env.GX_PORT || process.env.PORT || port;
66
97
 
67
98
  // logging
68
99
  this.#api.use(requestLogger);
69
100
 
70
101
  // cors
71
102
  this.#api.use((req, res, next) => {
103
+ const cors = this.#opts.cors;
72
104
  const origin = req.headers["origin"];
73
105
  const allMethods = "GET,PUT,POST,DELETE,OPTIONS,HEAD";
74
- const allHeaders = "*";
75
106
  const requestMethod = req.headers["access-control-request-method"];
76
107
  const requestHeaders = req.headers["access-control-request-headers"];
77
108
 
78
- res.set("Access-Control-Allow-Credentials", "true");
79
- res.set("Access-Control-Allow-Origin", origin || "*");
80
- res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
81
109
  res.set("Allow", requestMethod || allMethods);
82
- 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
+ }
83
135
 
84
136
  next();
85
137
  });
86
138
 
87
- // debug router (only available in non-production environments)
88
- if (process.env.NODE_ENV !== "production") {
89
- 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());
90
142
  }
91
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
+
92
149
  this.#api.use((req, res, next) => {
93
150
  if (this.#opts.beforeRequest) {
94
151
  this.#opts.beforeRequest(req, res);
@@ -126,18 +183,21 @@ export class Gateway {
126
183
  });
127
184
  });
128
185
 
129
- 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(() => {
130
190
  if (this.#rebuildRouter) {
131
191
  this.#buildRouter();
132
192
  }
133
193
  }, 1000);
134
194
 
135
- while (true) {
195
+ while (this.#isActive) {
136
196
  try {
137
197
  // send keeplive to check if connection is still alive
138
198
  await connection.publish("gx.gateway.keepalive", Date.now());
139
199
 
140
- await this.#handleAddedServics();
200
+ await this.#handleAddedServices();
141
201
  await this.#handleRemovedServices();
142
202
  await sleep(1000);
143
203
 
@@ -149,41 +209,31 @@ export class Gateway {
149
209
  }
150
210
  }
151
211
 
212
+ this.#server?.close();
213
+
152
214
  // terminate process
153
- logger.debug("geonix.gateway: stopped");
154
- process.exit(0);
215
+ logger.info("geonix.gateway: stopped");
155
216
  }
156
217
 
157
- async #handleAddedServics() {
218
+ async #handleAddedServices() {
158
219
  let entries = Object.values(registry.getEntries());
159
220
 
160
221
  const processEntry = async (entry) => {
161
- if (this.#registry[entry.i] !== undefined) { return false; }
162
-
163
- logger.info(`gateway.onServiceAdded: ${entry.n}@${entry.v} (#${entry.i})`);
222
+ const existing = this.#registry[entry.i];
164
223
 
165
- // figure out if endpoints is reachable via direct http call
166
- let backend;
167
- if (entry.a) {
168
- for (let address of entry.a) {
169
- try {
170
- const ac = new AbortController();
171
- const timeout = setTimeout(() => ac.abort(), 500);
172
- const result = await (await fetch(`http://${address}${HEALTH_CHECK_ENDPOINT}`, { signal: ac.signal })).json();
173
- clearTimeout(timeout);
174
- if (result.status === "healthy" && result.services?.includes(entry.n)) {
175
- backend = address;
176
- logger.info(`${entry.n}@${entry.v} (#${entry.i}) directly reachable @ ${address}`);
177
- break;
178
- }
179
- } catch {
180
- // silently ignore errors
181
- }
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;
182
229
  }
230
+ return false;
183
231
  }
184
232
 
233
+ logger.info(`gateway.onServiceAdded: ${entry.n}@${entry.v} (#${entry.i})`);
234
+
185
235
  let proxy;
186
- if (!backend) {
236
+ if (!entry.a?.length) {
187
237
  // create proxy over nats
188
238
  proxy = await createTCPServer(async (client) => {
189
239
  const streamId = picoid();
@@ -195,11 +245,10 @@ export class Gateway {
195
245
  logger.error("nats.proxy.error", e);
196
246
  client.destroy();
197
247
  }
198
- }, 50000, 10000);
199
- backend = `127.0.0.1:${proxy.port}`;
248
+ });
200
249
  }
201
250
 
202
- this.#registry[entry.i] = { entry, proxy, backend };
251
+ this.#registry[entry.i] = { entry, proxy, knownAddressCount: entry.a?.length ?? 0 };
203
252
 
204
253
  return true;
205
254
  };
@@ -265,7 +314,6 @@ export class Gateway {
265
314
  platform: process.platform,
266
315
  arch: process.arch
267
316
  },
268
- env: process.env,
269
317
  mem: process.memoryUsage(),
270
318
  rss: process.memoryUsage.rss(),
271
319
  cpu: process.cpuUsage()
@@ -288,15 +336,15 @@ export class Gateway {
288
336
  connection.unsubscribe(ingress);
289
337
  connection.publish(`gx2.stream.${streamId}.c`, Buffer.from("end"));
290
338
  });
291
- client.on("error", (_error) => {
292
- // silently ignore errors
339
+ client.on("error", (e) => {
340
+ logger.debug("nats.proxy.client.error:", e);
293
341
  });
294
342
 
295
343
  const dataLoop = async () => {
296
344
  for await (const event of ingress) { client.write(event.data); }
297
345
  };
298
346
 
299
- dataLoop();
347
+ dataLoop().catch(e => logger.error("nats.proxy.dataLoop:", e));
300
348
  }
301
349
  }
302
350
 
@@ -307,12 +355,13 @@ export class Gateway {
307
355
  * @param {Readable} inbound
308
356
  * @param {Request} req
309
357
  */
310
- #proxyWebsocketOverNats(target, inbound, req) {
358
+ #proxyWebsocket(target, inbound, req) {
311
359
  try {
312
- const backend = new WebSocket(target, {
313
- headers: {
314
- ...req.headers
315
- }
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();
316
365
  });
317
366
 
318
367
  backend.on("open", () => {
@@ -323,7 +372,8 @@ export class Gateway {
323
372
  inbound.on("close", () => backend.close());
324
373
  });
325
374
  } catch (e) {
326
- logger.error(e);
375
+ logger.error("proxy.ws.backend.error:", e);
376
+ inbound.close();
327
377
  }
328
378
  }
329
379
 
@@ -343,14 +393,23 @@ export class Gateway {
343
393
 
344
394
  const endpoints = [];
345
395
 
346
- 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
+
347
403
  // generate global endpoint list
348
404
  for (let e of entry.m) {
349
- if (endpointMatcher.test(e)) {
350
- 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);
351
410
  let options = {
352
- order: 100,
353
- ...(endpoint.options ? querystring.parse(endpoint.options) : {})
411
+ ...parsed,
412
+ order: Number.isNaN(parsedOrder) ? 100 : parsedOrder
354
413
  };
355
414
 
356
415
  try {
@@ -359,7 +418,7 @@ export class Gateway {
359
418
  version: semver.coerce(entry.v).version,
360
419
  options,
361
420
  endpoint,
362
- backend: [backend]
421
+ backend: [backendAddr]
363
422
  });
364
423
  } catch (e) {
365
424
  logger.error("gateway.buildRouter.error:", entry);
@@ -386,28 +445,45 @@ export class Gateway {
386
445
 
387
446
  // sort endpoints by order, if there is one
388
447
  endpoints.sort((a, b) => semver.rcompare(a.version, b.version));
389
- endpoints.sort((a, b) => parseInt(a.options.order) - parseInt(b.options.order));
448
+ endpoints.sort((a, b) => a.options.order - b.options.order);
390
449
 
391
450
  this.#endpoints = endpoints;
392
451
 
393
452
  // build the router
394
453
  for (let endpoint of endpoints) {
395
454
  let { verb, url: uri } = endpoint.endpoint;
396
- let backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
397
-
398
455
  verb = verb.toLowerCase();
399
456
 
400
457
  if (verb === "ws") {
401
458
  router.ws(uri, (ws, req) => {
402
- 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
+ }
403
478
 
479
+ const target = cleanupWebsocketUrl(`ws://${selectedBackend}${req.originalUrl}`);
404
480
  logger.debug("proxy.web.ws.to:", target);
405
- this.#proxyWebsocketOverNats(target, ws, req);
481
+ this.#proxyWebsocket(target, ws, req);
406
482
  });
407
483
  } else {
408
484
  router[verb](uri, async (req, res, _next) => {
409
485
  stats.proxied++;
410
- backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
486
+ const backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
411
487
 
412
488
  try {
413
489
  logger.debug("proxy.web.to:", backend + req.originalUrl);
@@ -425,4 +501,13 @@ export class Gateway {
425
501
  }
426
502
  }
427
503
 
504
+ /**
505
+ * Stops the gateway loop and closes the HTTP server.
506
+ * @returns {void}
507
+ */
508
+ stop() {
509
+ this.#isActive = false;
510
+ clearInterval(this.#rebuildRouterInterval);
511
+ }
512
+
428
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();