geonix 1.30.2 → 1.31.0

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/README.md CHANGED
@@ -287,7 +287,7 @@ Each returned part has:
287
287
 
288
288
  | Env Variable | Default | Description |
289
289
  |---|---|---|
290
- | `GX_TRANSPORT` | `nats://localhost` | NATS server URL(s) |
290
+ | `GX_TRANSPORT` | `nats://localhost` | NATS server URL(s). Set to `local://` for single-process monolith mode — see "Local transport" below. |
291
291
  | `GX_PORT` | `8080` | Gateway listen port |
292
292
  | `GX_LOCAL_PORT` | random | Force service HTTP server to a specific port |
293
293
  | `GX_VERSION` | `999.999.<seconds>` | Service version |
@@ -298,6 +298,14 @@ Each returned part has:
298
298
  | `GX_SECRET` | — | Encryption key: AES-256-GCM payloads + HMAC-SHA256 subjects. Services without the same key cannot communicate. |
299
299
  | `GX_DEBUG_ENDPOINT` | — | Mount path for the debug router (e.g. `/_debug`). Disabled when unset. |
300
300
 
301
+ ## Local transport (`GX_TRANSPORT=local://`)
302
+
303
+ For single-process monolith deployments, set `GX_TRANSPORT=local://` to replace the NATS connection with an in-process pub/sub bus. Services running in the same Node process announce themselves, the registry populates, and `Remote<T>()` calls work end-to-end without a `nats-server` running anywhere.
304
+
305
+ RPC traffic still flows through HTTP-on-loopback (`127.0.0.1:<servicePort>/!!_gx/rpc/...`) — the bus only replaces the discovery and NATS-control plane. The wire contract is identical to a distributed deployment, so migrating from monolith to multi-host is a single env var change (`GX_TRANSPORT=nats://your-host:4222`); no code changes.
306
+
307
+ Semantics matched against real NATS: subject wildcards (`*`, `>`), queue groups, `{ max: N }`, FIFO per subscription, byte payloads with defensive copy on publish. Not supported in local mode: multi-process (the bus is per-process), JetStream, and any feature Geonix doesn't already use.
308
+
301
309
  > **Deprecation notice:** The legacy unprefixed names `TRANSPORT`, `PORT`, `LOCAL_PORT`, `VERSION`, and `TRANSPORT_DEBUG` are still read as fallbacks but will be removed in the next major version. Migrate to the `GX_` prefixed names.
302
310
 
303
311
  ### Bus Encryption (`GX_SECRET`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geonix",
3
- "version": "1.30.2",
3
+ "version": "1.31.0",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "bin": {
@@ -34,4 +34,4 @@
34
34
  "eslint": "^10.1.0",
35
35
  "globals": "^17.4.0"
36
36
  }
37
- }
37
+ }
package/src/Codec.js CHANGED
@@ -30,4 +30,4 @@ export function decode(data) {
30
30
  } catch (e) {
31
31
  throw new Error(`Codec.decode: ${e.message}`, { cause: e });
32
32
  }
33
- }
33
+ }
package/src/Connection.js CHANGED
@@ -10,7 +10,7 @@ import { encryptPayload, decryptPayload, encryptSubject, wrapSubscription } from
10
10
  const CONNECTION_TIMEOUT = 10000;
11
11
 
12
12
  const defaultRequestOptions = {
13
- timeout: 300000
13
+ timeout: 300000,
14
14
  };
15
15
 
16
16
  const DEFAULT_CONNECTION_COUNT = 1;
@@ -21,7 +21,16 @@ const defaultConnectionOptions = {
21
21
  debug: (process.env.GX_TRANSPORT_DEBUG || process.env.TRANSPORT_DEBUG) === "true",
22
22
  maxReconnectAttempts: 30,
23
23
  pingInterval: 30000,
24
- waitOnFirstConnect: true
24
+ waitOnFirstConnect: true,
25
+ };
26
+
27
+ const TRANSPORT_TIMEOUT = 60_000;
28
+ const WATCHDOG_INTERVAL = 1000;
29
+
30
+ // env read via a function so tests can set GX_TRANSPORT_TIMEOUT after this module is imported
31
+ const getTransportTimeout = () => {
32
+ const raw = parseInt(process.env.GX_TRANSPORT_TIMEOUT, 10);
33
+ return Number.isFinite(raw) ? raw : TRANSPORT_TIMEOUT;
25
34
  };
26
35
  // -------------------------------------------------------------------------------------------------
27
36
 
@@ -32,12 +41,13 @@ const defaultConnectionOptions = {
32
41
  * encryption are activated by setting the `GX_SECRET` environment variable.
33
42
  */
34
43
  class Connection {
35
-
36
44
  #draining = false;
37
45
  #closed = false;
38
46
  #ready = false;
39
47
  #connections = [];
40
48
  #connectionRoundRobin = 0;
49
+ #disconnectedSince = Date.now();
50
+ #watchdogInterval = null;
41
51
 
42
52
  #getConnection() {
43
53
  return this.#connections[this.#connectionRoundRobin++ % this.#connections.length];
@@ -52,22 +62,67 @@ class Connection {
52
62
  * @returns {Promise<void>}
53
63
  */
54
64
  async start(transport = process.env.GX_TRANSPORT || process.env.TRANSPORT || "nats://localhost") {
65
+ // local:// — in-process pub/sub bus, no socket, no nats package call
66
+ if (transport === "local://" || transport === "local") {
67
+ const { createLocalBus } = await import("./LocalBus.js");
68
+ this.#startWatchdog();
69
+ this.#connections.push(createLocalBus());
70
+ this.#disconnectedSince = null;
71
+ logger.info("gx.connection.connected (local)");
72
+ this.#ready = true;
73
+ this.monitorStatus();
74
+ this.waitUntilClosed().catch((e) => logger.error("gx.connection.waitUntilClosed:", e));
75
+ return;
76
+ }
77
+
55
78
  const { connections: _connectionCount, ...natsOptions } = {
56
79
  ...defaultConnectionOptions,
57
- ...parseURL(transport)
80
+ ...parseURL(transport),
58
81
  };
59
82
  const connectionCount = parseInt(_connectionCount) || DEFAULT_CONNECTION_COUNT;
60
83
 
84
+ this.#startWatchdog();
85
+
61
86
  for (let i = 0; i < connectionCount; i++) {
62
87
  this.#connections.push(await connect(natsOptions));
63
88
  }
64
89
 
90
+ this.#disconnectedSince = null;
91
+
65
92
  logger.info("gx.connection.connected");
66
93
 
67
94
  this.#ready = true;
68
95
 
69
96
  this.monitorStatus();
70
- this.waitUntilClosed().catch(e => logger.error("gx.connection.waitUntilClosed:", e));
97
+ this.waitUntilClosed().catch((e) => logger.error("gx.connection.waitUntilClosed:", e));
98
+ }
99
+
100
+ // Polls the disconnect timestamp; exits the process if the transport has been
101
+ // unreachable longer than GX_TRANSPORT_TIMEOUT (default 60s, 0 disables).
102
+ #startWatchdog() {
103
+ if (this.#watchdogInterval) {
104
+ return;
105
+ }
106
+
107
+ this.#watchdogInterval = setInterval(() => {
108
+ const limit = getTransportTimeout();
109
+ if (limit === 0) {
110
+ return;
111
+ }
112
+ if (this.#disconnectedSince === null) {
113
+ return;
114
+ }
115
+
116
+ const elapsed = Date.now() - this.#disconnectedSince;
117
+ if (elapsed >= limit) {
118
+ logger.error(
119
+ `gx.connection: transport unreachable for ${Math.round(elapsed / 1000)}s (limit ${limit}ms), exiting`,
120
+ );
121
+ process.exit(1);
122
+ }
123
+ }, WATCHDOG_INTERVAL);
124
+
125
+ this.#watchdogInterval.unref?.();
71
126
  }
72
127
 
73
128
  /**
@@ -78,9 +133,17 @@ class Connection {
78
133
  for (const conn of this.#connections) {
79
134
  (async () => {
80
135
  for await (const event of conn.status()) {
136
+ if (event.type === "disconnect") {
137
+ if (this.#disconnectedSince === null) {
138
+ this.#disconnectedSince = Date.now();
139
+ }
140
+ } else if (event.type === "reconnect") {
141
+ this.#disconnectedSince = null;
142
+ }
143
+
81
144
  logger.debug("gx.connection.status", JSON.stringify(event));
82
145
  }
83
- })().catch(e => logger.error("gx.connection.status:", e));
146
+ })().catch((e) => logger.error("gx.connection.status:", e));
84
147
  }
85
148
  }
86
149
 
@@ -92,11 +155,16 @@ class Connection {
92
155
  */
93
156
  async waitUntilClosed() {
94
157
  // wait for all connections to be closed
95
- await Promise.all(this.#connections.map(connection => connection.closed()));
158
+ await Promise.all(this.#connections.map((connection) => connection.closed()));
96
159
 
97
160
  this.#closed = true;
98
161
  logger.info("gx.connection.closed");
99
162
 
163
+ if (this.#watchdogInterval) {
164
+ clearInterval(this.#watchdogInterval);
165
+ this.#watchdogInterval = null;
166
+ }
167
+
100
168
  webserver.stop();
101
169
 
102
170
  await sleep(5000);
@@ -118,9 +186,9 @@ class Connection {
118
186
 
119
187
  /**
120
188
  * Publish JSON
121
- *
122
- * @param {string} subject
123
- * @param {object} json
189
+ *
190
+ * @param {string} subject
191
+ * @param {object} json
124
192
  * @returns void
125
193
  */
126
194
  async publish(subject, json) {
@@ -150,21 +218,24 @@ class Connection {
150
218
  return;
151
219
  }
152
220
 
153
- await this.#getConnection().publish(encryptSubject(subject), encryptPayload(data != null ? Buffer.from(data) : Buffer.alloc(0)));
221
+ await this.#getConnection().publish(
222
+ encryptSubject(subject),
223
+ encryptPayload(data != null ? Buffer.from(data) : Buffer.alloc(0)),
224
+ );
154
225
  }
155
226
 
156
227
  /**
157
228
  * Request/Reply pattern on top of pub/sub
158
- *
159
- * @param {string} subject
160
- * @param {object} json
161
- * @param {object} options
229
+ *
230
+ * @param {string} subject
231
+ * @param {object} json
232
+ * @param {object} options
162
233
  * @returns any
163
234
  */
164
235
  async request(subject, json, opts = {}) {
165
236
  const options = {
166
237
  ...defaultRequestOptions,
167
- ...opts
238
+ ...opts,
168
239
  };
169
240
 
170
241
  const respondTo = `gx2.r.${picoid(16)}`;
@@ -235,9 +306,8 @@ class Connection {
235
306
  async drain() {
236
307
  this.#draining = true;
237
308
 
238
- await Promise.all(this.#connections.map(connection => connection.drain()));
309
+ await Promise.all(this.#connections.map((connection) => connection.drain()));
239
310
  }
240
-
241
311
  }
242
312
 
243
313
  /**
@@ -247,7 +317,7 @@ class Connection {
247
317
  * @type {Connection}
248
318
  */
249
319
  export const connection = new Connection();
250
- connection.start().catch(e => {
320
+ connection.start().catch((e) => {
251
321
  logger.error("gx.connection.start:", e);
252
322
  process.exit(1);
253
323
  });
package/src/Crypto.js CHANGED
@@ -3,7 +3,11 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes }
3
3
  const _secret = process.env.GX_SECRET || null;
4
4
 
5
5
  // Subject key is derived separately so HMAC-subject and AES-payload never share key material.
6
- const _subjectKey = _secret ? createHash("sha256").update(_secret + "\x00subject").digest() : null;
6
+ const _subjectKey = _secret
7
+ ? createHash("sha256")
8
+ .update(_secret + "\x00subject")
9
+ .digest()
10
+ : null;
7
11
 
8
12
  /**
9
13
  * AES-256-GCM key derived from `GX_SECRET`, or `null` when encryption is disabled.
@@ -11,7 +15,11 @@ const _subjectKey = _secret ? createHash("sha256").update(_secret + "\x00subject
11
15
  *
12
16
  * @type {Buffer|null}
13
17
  */
14
- export const _payloadKey = _secret ? createHash("sha256").update(_secret + "\x00payload").digest() : null;
18
+ export const _payloadKey = _secret
19
+ ? createHash("sha256")
20
+ .update(_secret + "\x00payload")
21
+ .digest()
22
+ : null;
15
23
 
16
24
  /**
17
25
  * Encrypts `data` with AES-256-GCM using {@link _payloadKey}. Returns `data` unchanged when
@@ -21,7 +29,9 @@ export const _payloadKey = _secret ? createHash("sha256").update(_secret + "\x00
21
29
  * @returns {Buffer}
22
30
  */
23
31
  export function encryptPayload(data) {
24
- if (!_payloadKey) { return data; }
32
+ if (!_payloadKey) {
33
+ return data;
34
+ }
25
35
  const buf = data != null ? Buffer.from(data) : Buffer.alloc(0);
26
36
  const iv = randomBytes(12);
27
37
  const cipher = createCipheriv("aes-256-gcm", _payloadKey, iv);
@@ -42,7 +52,9 @@ export function encryptPayload(data) {
42
52
  * @returns {Buffer}
43
53
  */
44
54
  export function decryptPayload(data) {
45
- if (!_payloadKey) { return data; }
55
+ if (!_payloadKey) {
56
+ return data;
57
+ }
46
58
  const buf = Buffer.from(data);
47
59
  const decipher = createDecipheriv("aes-256-gcm", _payloadKey, buf.subarray(0, 12));
48
60
  decipher.setAuthTag(buf.subarray(12, 28));
@@ -64,10 +76,12 @@ export function encryptSubject(subject) {
64
76
  return subject;
65
77
  }
66
78
 
67
- return subject.split(".").map(seg =>
68
- (seg === "*" || seg === ">") ? seg
69
- : createHmac("sha256", _subjectKey).update(seg).digest("hex").slice(0, 32)
70
- ).join(".");
79
+ return subject
80
+ .split(".")
81
+ .map((seg) =>
82
+ seg === "*" || seg === ">" ? seg : createHmac("sha256", _subjectKey).update(seg).digest("hex").slice(0, 32),
83
+ )
84
+ .join(".");
71
85
  }
72
86
 
73
87
  /**
@@ -94,7 +108,7 @@ export function wrapSubscription(sub) {
94
108
  }
95
109
 
96
110
  return { value: { ...value, data: decryptPayload(value.data) }, done: false };
97
- }
111
+ },
98
112
  };
99
113
  },
100
114
  drain: () => sub.drain(),
package/src/Gateway.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { connection } from "./Connection.js";
2
2
  import { registry } from "./Registry.js";
3
- import { cleanupWebsocketUrl, createTCPServer, GeonixVersion, hash, picoid, proxyHttp, sleep } from "./Util.js";
3
+ import { cleanupWebsocketUrl, createTCPServer, GeonixVersion, hash, isLoopbackAddress, picoid, proxyHttp, sleep } from "./Util.js";
4
4
  import express, { Router } from "express";
5
5
  import { Request } from "./Request.js";
6
6
  import expressWs from "express-ws";
@@ -15,12 +15,14 @@ const MAX_SESSIONS = 16384;
15
15
  // Real client IP — checks well-known CDN/proxy headers before falling back to socket address
16
16
  function getClientIp(req) {
17
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)
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
22
 
23
- if (header) { return header.split(",")[0].trim(); }
23
+ if (header) {
24
+ return header.split(",")[0].trim();
25
+ }
24
26
  return req.socket?.remoteAddress || "unknown";
25
27
  }
26
28
 
@@ -39,12 +41,12 @@ const stats = {
39
41
  requests: 0,
40
42
  proxied: 0,
41
43
  proxied_over_nats: 0,
42
- debug_requests: 0
44
+ debug_requests: 0,
43
45
  };
44
46
 
45
47
  const defaultOpts = {
46
- beforeRequest: (_req, _res) => { },
47
- afterRequest: (_req, _res) => { }
48
+ beforeRequest: (_req, _res) => {},
49
+ afterRequest: (_req, _res) => {},
48
50
  };
49
51
 
50
52
  /**
@@ -53,7 +55,6 @@ const defaultOpts = {
53
55
  * dynamically as services join and leave the bus.
54
56
  */
55
57
  export class Gateway {
56
-
57
58
  /**
58
59
  * Creates and starts a new Gateway instance.
59
60
  *
@@ -85,7 +86,7 @@ export class Gateway {
85
86
 
86
87
  this.#opts = { ...this.#opts, ...opts };
87
88
 
88
- this.#start().catch(e => logger.error("gx.gateway.start:", e));
89
+ this.#start().catch((e) => logger.error("gx.gateway.start:", e));
89
90
  }
90
91
 
91
92
  async #start(port = 8080) {
@@ -179,7 +180,7 @@ export class Gateway {
179
180
  this.#api.all("*", (req, res) => {
180
181
  res.status(404).send({
181
182
  error: 404,
182
- source: "gw"
183
+ source: "gw",
183
184
  });
184
185
  });
185
186
 
@@ -253,7 +254,7 @@ export class Gateway {
253
254
  return true;
254
255
  };
255
256
 
256
- entries = (await Promise.all(entries.map(processEntry))).filter(result => result === true);
257
+ entries = (await Promise.all(entries.map(processEntry))).filter((result) => result === true);
257
258
 
258
259
  if (entries.length > 0) {
259
260
  this.#rebuildRouter = true;
@@ -285,7 +286,7 @@ export class Gateway {
285
286
  });
286
287
 
287
288
  router.get("/services", (req, res) => {
288
- const services = Object.values(registry.getEntries()).map(e => (`${e.n}@${e.v}`));
289
+ const services = Object.values(registry.getEntries()).map((e) => `${e.n}@${e.v}`);
289
290
  services.sort();
290
291
  res.send(services);
291
292
  });
@@ -312,11 +313,11 @@ export class Gateway {
312
313
  node: {
313
314
  version: process.version,
314
315
  platform: process.platform,
315
- arch: process.arch
316
+ arch: process.arch,
316
317
  },
317
318
  mem: process.memoryUsage(),
318
319
  rss: process.memoryUsage.rss(),
319
- cpu: process.cpuUsage()
320
+ cpu: process.cpuUsage(),
320
321
  });
321
322
  });
322
323
 
@@ -341,19 +342,21 @@ export class Gateway {
341
342
  });
342
343
 
343
344
  const dataLoop = async () => {
344
- for await (const event of ingress) { client.write(event.data); }
345
+ for await (const event of ingress) {
346
+ client.write(event.data);
347
+ }
345
348
  };
346
349
 
347
- dataLoop().catch(e => logger.error("nats.proxy.dataLoop:", e));
350
+ dataLoop().catch((e) => logger.error("nats.proxy.dataLoop:", e));
348
351
  }
349
352
  }
350
353
 
351
354
  /**
352
355
  * Proxies websocket connection
353
- *
354
- * @param {string} target
355
- * @param {Readable} inbound
356
- * @param {Request} req
356
+ *
357
+ * @param {string} target
358
+ * @param {Readable} inbound
359
+ * @param {Request} req
357
360
  */
358
361
  #proxyWebsocket(target, inbound, req) {
359
362
  try {
@@ -366,7 +369,7 @@ export class Gateway {
366
369
 
367
370
  backend.on("open", () => {
368
371
  backend.on("message", (data, isBinary) => inbound.send(isBinary ? data : data.toString()));
369
- inbound.on("message", data => backend.send(data));
372
+ inbound.on("message", (data) => backend.send(data));
370
373
 
371
374
  backend.on("close", () => inbound.close());
372
375
  inbound.on("close", () => backend.close());
@@ -394,22 +397,32 @@ export class Gateway {
394
397
  const endpoints = [];
395
398
 
396
399
  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
+ let backendAddr;
401
+ if (proxy) {
402
+ backendAddr = `127.0.0.1:${proxy.port}`;
403
+ } else {
404
+ const addrs = entry.a ?? [];
405
+ const loopback = addrs.filter(isLoopbackAddress);
406
+ const pool = loopback.length > 0 ? loopback : addrs;
407
+ backendAddr = pool.length > 0 ? pool[Math.floor(Math.random() * pool.length)] : undefined;
408
+ }
400
409
 
401
- if (!backendAddr) { continue; }
410
+ if (!backendAddr) {
411
+ continue;
412
+ }
402
413
 
403
414
  // generate global endpoint list
404
415
  for (let e of entry.m) {
405
416
  const endpointMatch = endpointMatcher.exec(e);
406
417
  if (endpointMatch) {
407
418
  const endpoint = endpointMatch.groups;
408
- const parsed = endpoint.options ? Object.fromEntries(new URLSearchParams(endpoint.options)) : {};
419
+ const parsed = endpoint.options
420
+ ? Object.fromEntries(new URLSearchParams(endpoint.options))
421
+ : {};
409
422
  const parsedOrder = parseInt(parsed.order, 10);
410
423
  let options = {
411
424
  ...parsed,
412
- order: Number.isNaN(parsedOrder) ? 100 : parsedOrder
425
+ order: Number.isNaN(parsedOrder) ? 100 : parsedOrder,
413
426
  };
414
427
 
415
428
  try {
@@ -418,7 +431,7 @@ export class Gateway {
418
431
  version: semver.coerce(entry.v).version,
419
432
  options,
420
433
  endpoint,
421
- backend: [backendAddr]
434
+ backend: [backendAddr],
422
435
  });
423
436
  } catch (e) {
424
437
  logger.error("gateway.buildRouter.error:", entry);
@@ -435,7 +448,10 @@ export class Gateway {
435
448
  const url = `${endpoints[index].endpoint.verb} ${endpoints[index].endpoint.url}`;
436
449
 
437
450
  for (let n = 0; n < index; n++) {
438
- if (`${endpoints[n].endpoint.verb} ${endpoints[n].endpoint.url}` === url && endpoints[n].version === version) {
451
+ if (
452
+ `${endpoints[n].endpoint.verb} ${endpoints[n].endpoint.url}` === url &&
453
+ endpoints[n].version === version
454
+ ) {
439
455
  endpoints[n].backend = endpoints[n].backend.concat(endpoints[index].backend);
440
456
  endpoints.splice(index, 1);
441
457
  break;
@@ -509,5 +525,4 @@ export class Gateway {
509
525
  this.#isActive = false;
510
526
  clearInterval(this.#rebuildRouterInterval);
511
527
  }
512
-
513
528
  }