geonix 1.30.4 → 1.32.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`)
@@ -0,0 +1,223 @@
1
+ // Latency / throughput benchmark comparing NATS vs LocalBus.
2
+ //
3
+ // The parent process spawns a child per transport so each child gets a fresh
4
+ // module load (Connection.start() auto-runs on import and reads GX_TRANSPORT).
5
+ // Each child measures two paths and prints JSON; the parent tabulates.
6
+ //
7
+ // Service RPC end-to-end Remote(svc).method() — uses HTTP loopback
8
+ // for either transport (entries.length === 1 forces the
9
+ // HTTP-RPC branch in directRequest)
10
+ // connection.request raw pub/sub round-trip — measures the actual transport
11
+ //
12
+ // Run: `node benchmarks/transport.js`
13
+ // Options (env): BENCH_ITERATIONS=2000 BENCH_WARMUP=200 BENCH_PAYLOAD_SIZE=64
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, resolve } from "node:path";
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const root = resolve(here, "..");
21
+
22
+ const ITERATIONS = parseInt(process.env.BENCH_ITERATIONS) || 2000;
23
+ const WARMUP = parseInt(process.env.BENCH_WARMUP) || 200;
24
+ const PAYLOAD_SIZE = parseInt(process.env.BENCH_PAYLOAD_SIZE) || 64;
25
+
26
+ if (process.env.BENCH_CHILD) {
27
+ await runChild();
28
+ } else {
29
+ await runParent();
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Parent: spawn one child per transport, collect JSON, print a comparison
34
+ // ---------------------------------------------------------------------------
35
+
36
+ async function runParent() {
37
+ const transports = [
38
+ { name: "NATS", url: "nats://localhost" },
39
+ { name: "LocalBus", url: "local://" },
40
+ ];
41
+
42
+ process.stderr.write(`Iterations: ${ITERATIONS}, warmup: ${WARMUP}, payload: ${PAYLOAD_SIZE} bytes\n\n`);
43
+
44
+ const results = [];
45
+ for (const t of transports) {
46
+ process.stderr.write(`Running ${t.name} (${t.url})... `);
47
+ const t0 = Date.now();
48
+ try {
49
+ const r = await runChildProcess(t.url);
50
+ process.stderr.write(`ok (${((Date.now() - t0) / 1000).toFixed(1)}s)\n`);
51
+ results.push({ ...t, ...r });
52
+ } catch (e) {
53
+ process.stderr.write(`FAILED: ${e.message}\n`);
54
+ results.push({ ...t, error: e.message });
55
+ }
56
+ }
57
+
58
+ printTable(results);
59
+ }
60
+
61
+ function runChildProcess(transport) {
62
+ return new Promise((res, rej) => {
63
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], {
64
+ env: {
65
+ ...process.env,
66
+ GX_TRANSPORT: transport,
67
+ GX_LOG_LEVEL: "none",
68
+ BENCH_CHILD: "1",
69
+ },
70
+ stdio: ["ignore", "pipe", "pipe"],
71
+ cwd: root,
72
+ });
73
+ let stdout = "";
74
+ let stderr = "";
75
+ child.stdout.on("data", (c) => stdout += c.toString());
76
+ child.stderr.on("data", (c) => stderr += c.toString());
77
+ const timer = setTimeout(() => {
78
+ child.kill("SIGKILL");
79
+ rej(new Error(`child timed out after 120s; stderr=${stderr.slice(0, 500)}`));
80
+ }, 120_000);
81
+ child.on("exit", (code) => {
82
+ clearTimeout(timer);
83
+ if (code !== 0) {
84
+ return rej(new Error(`exit ${code}; stderr=${stderr.slice(0, 500)}`));
85
+ }
86
+ try {
87
+ res(JSON.parse(stdout));
88
+ } catch (_e) {
89
+ rej(new Error(`bad JSON from child; stdout=${stdout.slice(0, 500)}`));
90
+ }
91
+ });
92
+ });
93
+ }
94
+
95
+ function printTable(results) {
96
+ const header = ["transport", "path", "mean (ms)", "p50", "p95", "p99", "throughput (rps)"];
97
+ const rows = [];
98
+ for (const r of results) {
99
+ if (r.error) {
100
+ rows.push([r.name, "—", "error", "—", "—", "—", r.error.slice(0, 50)]);
101
+ continue;
102
+ }
103
+ const s = r.serviceRPC;
104
+ const c = r.connectionRequest;
105
+ rows.push([
106
+ r.name,
107
+ "Service RPC (Remote)",
108
+ s.mean.toFixed(3),
109
+ s.p50.toFixed(3),
110
+ s.p95.toFixed(3),
111
+ s.p99.toFixed(3),
112
+ Math.round(s.throughput).toString(),
113
+ ]);
114
+ rows.push([
115
+ r.name,
116
+ "connection.request",
117
+ c.mean.toFixed(3),
118
+ c.p50.toFixed(3),
119
+ c.p95.toFixed(3),
120
+ c.p99.toFixed(3),
121
+ Math.round(c.throughput).toString(),
122
+ ]);
123
+ }
124
+
125
+ const widths = header.map((h, i) =>
126
+ Math.max(h.length, ...rows.map((r) => String(r[i]).length))
127
+ );
128
+ const fmt = (row) => "│ " + row.map((v, i) => String(v).padEnd(widths[i])).join(" │ ") + " │";
129
+ const sep = (l, m, r) => l + widths.map((w) => "─".repeat(w + 2)).join(m) + r;
130
+
131
+ console.log();
132
+ console.log(sep("┌", "┬", "┐"));
133
+ console.log(fmt(header));
134
+ console.log(sep("├", "┼", "┤"));
135
+ for (const row of rows) {
136
+ console.log(fmt(row));
137
+ }
138
+ console.log(sep("└", "┴", "┘"));
139
+ console.log();
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Child: actually run the benchmark and print JSON to stdout
144
+ // ---------------------------------------------------------------------------
145
+
146
+ async function runChild() {
147
+ const { Service, connection, registry, Remote } = await import("../exports.js");
148
+ const { decode } = await import("../src/Codec.js");
149
+ const { uniqueName, waitFor } = await import("../tests/helpers.js");
150
+
151
+ await connection.waitUntilReady();
152
+
153
+ const SVC = uniqueName("BenchSvc");
154
+ class BenchSvc extends Service {
155
+ echo(v) { return v; }
156
+ }
157
+ BenchSvc.start({ name: SVC });
158
+ await waitFor(() => registry.getIdentifier(SVC), 5000);
159
+
160
+ const payload = "x".repeat(PAYLOAD_SIZE);
161
+
162
+ function percentile(arr, p) {
163
+ const sorted = arr.slice().sort((a, b) => a - b);
164
+ return sorted[Math.min(Math.floor(sorted.length * p), sorted.length - 1)];
165
+ }
166
+
167
+ function summary(latencies, totalSeconds) {
168
+ return {
169
+ iterations: latencies.length,
170
+ mean: latencies.reduce((a, b) => a + b, 0) / latencies.length,
171
+ p50: percentile(latencies, 0.50),
172
+ p95: percentile(latencies, 0.95),
173
+ p99: percentile(latencies, 0.99),
174
+ throughput: latencies.length / totalSeconds,
175
+ };
176
+ }
177
+
178
+ // Path 1: Service RPC via Remote (HTTP-loopback under the hood)
179
+ const remote = Remote(SVC);
180
+ for (let i = 0; i < WARMUP; i++) {
181
+ await remote.echo(payload);
182
+ }
183
+ let latencies = [];
184
+ let runStart = process.hrtime.bigint();
185
+ for (let i = 0; i < ITERATIONS; i++) {
186
+ const t0 = process.hrtime.bigint();
187
+ await remote.echo(payload);
188
+ const t1 = process.hrtime.bigint();
189
+ latencies.push(Number(t1 - t0) / 1e6);
190
+ }
191
+ const serviceRPC = summary(latencies, Number(process.hrtime.bigint() - runStart) / 1e9);
192
+
193
+ // Path 2: direct connection.request (pure transport pub/sub round-trip)
194
+ const subject = `bench.rr.${Date.now()}.${Math.random().toString(36).slice(2)}`;
195
+ const responder = await connection.subscribe(subject);
196
+ (async () => {
197
+ for await (const event of responder) {
198
+ const call = decode(event.data);
199
+ await connection.publish(call.$r, call.p);
200
+ }
201
+ })().catch(() => { /* ignore on shutdown */ });
202
+
203
+ for (let i = 0; i < WARMUP; i++) {
204
+ await connection.request(subject, payload);
205
+ }
206
+ latencies = [];
207
+ runStart = process.hrtime.bigint();
208
+ for (let i = 0; i < ITERATIONS; i++) {
209
+ const t0 = process.hrtime.bigint();
210
+ await connection.request(subject, payload);
211
+ const t1 = process.hrtime.bigint();
212
+ latencies.push(Number(t1 - t0) / 1e6);
213
+ }
214
+ const connectionRequest = summary(latencies, Number(process.hrtime.bigint() - runStart) / 1e9);
215
+ responder.unsubscribe();
216
+
217
+ process.stdout.write(JSON.stringify({
218
+ payloadSize: PAYLOAD_SIZE,
219
+ serviceRPC,
220
+ connectionRequest,
221
+ }));
222
+ process.exit(0);
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geonix",
3
- "version": "1.30.4",
3
+ "version": "1.32.0",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "test:unit": "GX_LOG_LEVEL=none node --test --test-reporter=spec tests/unit/*.test.js",
14
14
  "test:integration": "GX_LOG_LEVEL=none node --test --test-concurrency=1 --test-reporter=spec tests/integration/*.test.js",
15
15
  "lint": "npx eslint src",
16
+ "bench": "node benchmarks/transport.js",
16
17
  "deploy": "npm run build && npm publish"
17
18
  },
18
19
  "author": "Davor Tarandek <dtarandek@tria.hr>",
package/src/Connection.js CHANGED
@@ -62,6 +62,19 @@ class Connection {
62
62
  * @returns {Promise<void>}
63
63
  */
64
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
+
65
78
  const { connections: _connectionCount, ...natsOptions } = {
66
79
  ...defaultConnectionOptions,
67
80
  ...parseURL(transport),
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";
@@ -223,6 +223,16 @@ export class Gateway {
223
223
  const existing = this.#registry[entry.i];
224
224
 
225
225
  if (existing !== undefined) {
226
+ const hasAddresses = entry.a?.length > 0;
227
+ // Graduate from a NATS-tunnel proxy to direct HTTP once addresses become reachable
228
+ if (existing.proxy && hasAddresses) {
229
+ logger.info(`gateway.upgradeToDirect: ${entry.n}@${entry.v} (#${entry.i})`);
230
+ existing.proxy.server?.close();
231
+ existing.proxy = undefined;
232
+ existing.knownAddressCount = entry.a.length;
233
+ this.#rebuildRouter = true;
234
+ return false;
235
+ }
226
236
  // trigger rebuild if a direct entry has gained new addresses since last seen
227
237
  if (!existing.proxy && entry.a?.length !== existing.knownAddressCount) {
228
238
  existing.knownAddressCount = entry.a.length;
@@ -397,9 +407,15 @@ export class Gateway {
397
407
  const endpoints = [];
398
408
 
399
409
  for (let { entry, proxy } of entries) {
400
- const backendAddr = proxy
401
- ? `127.0.0.1:${proxy.port}`
402
- : entry.a?.[Math.floor(Math.random() * entry.a.length)];
410
+ let backendAddr;
411
+ if (proxy) {
412
+ backendAddr = `127.0.0.1:${proxy.port}`;
413
+ } else {
414
+ const addrs = entry.a ?? [];
415
+ const loopback = addrs.filter(isLoopbackAddress);
416
+ const pool = loopback.length > 0 ? loopback : addrs;
417
+ backendAddr = pool.length > 0 ? pool[Math.floor(Math.random() * pool.length)] : undefined;
418
+ }
403
419
 
404
420
  if (!backendAddr) {
405
421
  continue;
@@ -0,0 +1,190 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ const LOCAL_MAX_PAYLOAD = 8 * 1024 * 1024;
4
+
5
+ /**
6
+ * NATS-style subject matcher. Tokens split on `.`.
7
+ * `*` matches exactly one token.
8
+ * `>` matches one or more trailing tokens; must be the last token in the pattern.
9
+ *
10
+ * @param {string} pattern
11
+ * @param {string} subject
12
+ * @returns {boolean}
13
+ */
14
+ export function subjectMatch(pattern, subject) {
15
+ const p = pattern.split(".");
16
+ const s = subject.split(".");
17
+ for (let i = 0; i < p.length; i++) {
18
+ if (p[i] === ">") {
19
+ return i === p.length - 1 && s.length > i;
20
+ }
21
+ if (i >= s.length) {
22
+ return false;
23
+ }
24
+ if (p[i] !== "*" && p[i] !== s[i]) {
25
+ return false;
26
+ }
27
+ }
28
+ return p.length === s.length;
29
+ }
30
+
31
+ /**
32
+ * A single subscription on the in-memory bus. Async iterable; yields `{ data, subject }`
33
+ * events in publish order. Auto-closes after `max` deliveries when `max` is set.
34
+ */
35
+ class LocalSubscription {
36
+ #subject;
37
+ #queueGroup;
38
+ #max;
39
+ #count = 0;
40
+ #queue = [];
41
+ #waiters = [];
42
+ #closed = false;
43
+ #onClose;
44
+
45
+ constructor(subject, options, onClose) {
46
+ this.#subject = subject;
47
+ this.#queueGroup = options?.queue;
48
+ this.#max = options?.max;
49
+ this.#onClose = onClose;
50
+ }
51
+
52
+ get subject() { return this.#subject; }
53
+ get queue() { return this.#queueGroup; }
54
+ get isClosed() { return this.#closed; }
55
+
56
+ deliver(event) {
57
+ if (this.#closed) { return; }
58
+ this.#count++;
59
+ if (this.#waiters.length > 0) {
60
+ this.#waiters.shift().resolve({ value: event, done: false });
61
+ } else {
62
+ this.#queue.push(event);
63
+ }
64
+ if (this.#max != null && this.#count >= this.#max) {
65
+ this.#close();
66
+ }
67
+ }
68
+
69
+ #close() {
70
+ if (this.#closed) { return; }
71
+ this.#closed = true;
72
+ this.#onClose?.(this);
73
+ for (const w of this.#waiters) {
74
+ w.resolve({ value: undefined, done: true });
75
+ }
76
+ this.#waiters = [];
77
+ }
78
+
79
+ unsubscribe() { this.#close(); }
80
+ drain() { this.#close(); return Promise.resolve(); }
81
+
82
+ [Symbol.asyncIterator]() {
83
+ return {
84
+ next: () => {
85
+ if (this.#queue.length > 0) {
86
+ return Promise.resolve({ value: this.#queue.shift(), done: false });
87
+ }
88
+ if (this.#closed) {
89
+ return Promise.resolve({ value: undefined, done: true });
90
+ }
91
+ const w = Promise.withResolvers();
92
+ this.#waiters.push(w);
93
+ return w.promise;
94
+ },
95
+ return: () => {
96
+ this.#close();
97
+ return Promise.resolve({ value: undefined, done: true });
98
+ },
99
+ };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * In-memory NATS-shaped pub/sub bus. Implements the subset of `NatsConnection` that Geonix
105
+ * consumes: `publish`, `subscribe`, `drain`, `closed`, `status`, `info.max_payload`.
106
+ * Single-process only — no socket, no inter-process delivery.
107
+ *
108
+ * Semantics matched against real NATS:
109
+ * - Wildcards (`*`, `>`) per NATS subject spec.
110
+ * - Queue groups: one delivery per group per publish; round-robin within the group.
111
+ * - Plain (non-queue) subscriptions: each receives its own copy of every match.
112
+ * - `{ max: N }` auto-closes the subscription after N deliveries.
113
+ * - Within a subscription, FIFO.
114
+ * - Defensive copy on publish (publisher mutation after publish is safe).
115
+ */
116
+ class LocalBus {
117
+ #subs = new Set();
118
+ #info = { max_payload: LOCAL_MAX_PAYLOAD };
119
+ #closed = false;
120
+ #closedDeferred = Promise.withResolvers();
121
+ #queueCounters = new Map();
122
+
123
+ get info() { return this.#info; }
124
+
125
+ isClosed() { return this.#closed; }
126
+
127
+ async publish(subject, data) {
128
+ if (this.#closed) { return; }
129
+
130
+ const buf = Buffer.from(data);
131
+
132
+ const byQueue = new Map();
133
+ const plain = [];
134
+ for (const sub of this.#subs) {
135
+ if (!subjectMatch(sub.subject, subject)) { continue; }
136
+ if (sub.queue) {
137
+ if (!byQueue.has(sub.queue)) { byQueue.set(sub.queue, []); }
138
+ byQueue.get(sub.queue).push(sub);
139
+ } else {
140
+ plain.push(sub);
141
+ }
142
+ }
143
+
144
+ for (const sub of plain) {
145
+ sub.deliver({ data: buf, subject });
146
+ }
147
+
148
+ for (const [group, members] of byQueue) {
149
+ const idx = (this.#queueCounters.get(group) ?? 0) % members.length;
150
+ this.#queueCounters.set(group, idx + 1);
151
+ members[idx].deliver({ data: buf, subject });
152
+ }
153
+ }
154
+
155
+ async subscribe(subject, options) {
156
+ if (this.#closed) {
157
+ throw new Error("LocalBus: subscribe on closed bus");
158
+ }
159
+ const sub = new LocalSubscription(subject, options, (s) => this.#subs.delete(s));
160
+ this.#subs.add(sub);
161
+ return sub;
162
+ }
163
+
164
+ async drain() {
165
+ if (this.#closed) { return; }
166
+ for (const sub of [...this.#subs]) {
167
+ sub.unsubscribe();
168
+ }
169
+ this.#closed = true;
170
+ this.#closedDeferred.resolve();
171
+ }
172
+
173
+ closed() { return this.#closedDeferred.promise; }
174
+
175
+ status() {
176
+ // never-yielding async iterable — the bus has no transport to lose
177
+ return {
178
+ [Symbol.asyncIterator]() {
179
+ return {
180
+ next: () => new Promise(() => { /* never resolves */ }),
181
+ return: () => Promise.resolve({ value: undefined, done: true }),
182
+ };
183
+ },
184
+ };
185
+ }
186
+ }
187
+
188
+ export function createLocalBus() {
189
+ return new LocalBus();
190
+ }
package/src/Registry.js CHANGED
@@ -60,6 +60,9 @@ class Registry extends EventEmitter {
60
60
  }
61
61
 
62
62
  async #registerService(data) {
63
+ // Capture the full advertised list before probing collapses data.a to the healthy subset
64
+ const advertised = Array.isArray(data.a) ? [...data.a] : [];
65
+
63
66
  if (data.a?.length > 0) {
64
67
  const allAddresses = data.a;
65
68
  data.a = [];
@@ -89,6 +92,7 @@ class Registry extends EventEmitter {
89
92
 
90
93
  this.#registry[data.i] = {
91
94
  ...data,
95
+ advertised,
92
96
  timeout: Date.now() + REGISTRY_ENTRY_TIMEOUT,
93
97
  };
94
98
 
@@ -102,6 +106,51 @@ class Registry extends EventEmitter {
102
106
  this.emit("added", this.#registry[data.i]);
103
107
  }
104
108
 
109
+ // Returns true when both arrays contain the same addresses (order-insensitive).
110
+ #sameAddresses(a, b) {
111
+ if (a.length !== b.length) { return false; }
112
+ const set = new Set(a);
113
+ for (const x of b) {
114
+ if (!set.has(x)) { return false; }
115
+ }
116
+ return true;
117
+ }
118
+
119
+ // Single-flight re-probe: rebuilds `entry.a` from `entry.advertised`. If a new advertised
120
+ // list arrives while a probe is running, queues exactly one re-run via probeStale.
121
+ async #reprobe(entry) {
122
+ if (entry.probeInFlight) {
123
+ entry.probeStale = true;
124
+ return;
125
+ }
126
+ entry.probeInFlight = true;
127
+ try {
128
+ do {
129
+ entry.probeStale = false;
130
+ const candidates = [...(entry.advertised ?? [])];
131
+ const healthy = [];
132
+ await Promise.all(candidates.map((a) => this.#checkHealth(a, healthy, () => { })));
133
+ entry.a = healthy;
134
+ } while (entry.probeStale);
135
+ } finally {
136
+ entry.probeInFlight = false;
137
+ }
138
+ }
139
+
140
+ // Refresh timeout for a known entry; if the beacon carries an `.a` that differs from
141
+ // the cached advertised list, kick off a re-probe to rebuild the healthy subset.
142
+ #refreshKnown(entry, advertised) {
143
+ entry.timeout = Date.now() + REGISTRY_ENTRY_TIMEOUT;
144
+ if (!Array.isArray(advertised)) {
145
+ return;
146
+ }
147
+ if (this.#sameAddresses(entry.advertised ?? [], advertised)) {
148
+ return;
149
+ }
150
+ entry.advertised = [...advertised];
151
+ this.#reprobe(entry).catch((e) => logger.error("registry.reprobe:", e));
152
+ }
153
+
105
154
  async #beaconListener() {
106
155
  const subscription = await connection.subscribe("gx2.beacon");
107
156
 
@@ -110,18 +159,18 @@ class Registry extends EventEmitter {
110
159
 
111
160
  const exists = this.#registry[data.i] !== undefined;
112
161
 
113
- // if this is a short beacon, request full service info on first discovery only
162
+ // Short beacon (no `.n`). May still carry an `.a` we should react to.
114
163
  if (!data.n) {
115
164
  if (exists) {
116
- this.#registry[data.i].timeout = Date.now() + REGISTRY_ENTRY_TIMEOUT;
165
+ this.#refreshKnown(this.#registry[data.i], data.a);
117
166
  continue;
118
167
  }
119
168
  data = await directRequest(data.i, "$getServiceInfo");
120
169
  }
121
170
 
122
- // known service — just refresh timeout, no re-probing
171
+ // Full beacon for an already-known service: refresh timeout and react to `.a` changes.
123
172
  if (exists) {
124
- this.#registry[data.i].timeout = Date.now() + REGISTRY_ENTRY_TIMEOUT;
173
+ this.#refreshKnown(this.#registry[data.i], data.a);
125
174
  continue;
126
175
  }
127
176
 
package/src/Request.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { connection } from "./Connection.js";
2
2
  import { registry } from "./Registry.js";
3
- import { fetchWithTimeout, hash } from "./Util.js";
3
+ import { fetchWithTimeout, hash, isLoopbackAddress } from "./Util.js";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { _requestOptionsSymbol } from "./RequestOptions.js";
6
6
  import { isStream, streamToString } from "./Stream.js";
@@ -98,7 +98,10 @@ export async function directRequest(identifier, method, args, context, options,
98
98
  if (entries.length === 1) {
99
99
  const addresses = entries[0].a || [];
100
100
  if (addresses.length > 0) {
101
- const address = addresses[_httpRpcRoundRobin++ % addresses.length];
101
+ const loopback = addresses.filter(isLoopbackAddress);
102
+ const remote = addresses.filter((a) => !isLoopbackAddress(a));
103
+ const pool = loopback.length > 0 ? loopback : remote;
104
+ const address = pool[_httpRpcRoundRobin++ % pool.length];
102
105
 
103
106
  const url = `http://${address}/!!_gx/rpc/${hash(entries[0].i)}`;
104
107
  const rpcPayload = { m: method, a: args, c: context, o: originator };
package/src/Service.js CHANGED
@@ -157,7 +157,15 @@ export class Service {
157
157
  */
158
158
  async #beacon() {
159
159
  while (this.#isActive) {
160
- const payload = this.#options.fullBeacon ? this.#me : { i: this.#me.i };
160
+ // Refresh addresses each tick so a service that came up before its network was
161
+ // fully configured self-heals on the next beacon. Only `.a` can change at runtime;
162
+ // identity fields (i, n, v, m, gx) are immutable post-startup.
163
+ this.#me.a = getNetworkAddresses().map((address) => `${address}:${webserver.getPort()}`);
164
+
165
+ const payload = this.#options.fullBeacon
166
+ ? this.#me
167
+ : { i: this.#me.i, a: this.#me.a };
168
+
161
169
  connection.publish("gx2.beacon", payload).catch((e) => logger.warn("beacon.publish:", e));
162
170
  await sleep(BEACON_INTERVAL);
163
171
  }
package/src/Util.js CHANGED
@@ -203,24 +203,31 @@ export async function getFirstItemFromAsyncIterable(asyncIterable) {
203
203
  }
204
204
 
205
205
  export function getNetworkAddresses() {
206
- const list = [];
207
- const interfaces = networkInterfaces();
208
-
209
- for (let interfaceAddresses of Object.values(interfaces)) {
210
- for (let addressObject of interfaceAddresses) {
211
- if (addressObject.family === "IPv4") {
212
- list.push(addressObject.address);
213
- }
214
-
215
- if (addressObject.family === "IPv6") {
216
- list.push(`[${addressObject.address}]`);
217
- }
206
+ // Loopback entries are seeded first so same-host callers prefer them; the OS-reported
207
+ // duplicates are filtered out below to keep the prepended ones canonical.
208
+ const list = ["127.0.0.1", "[::1]"];
209
+ const interfaces = networkInterfaces() ?? {};
210
+
211
+ for (const interfaceAddresses of Object.values(interfaces)) {
212
+ if (!interfaceAddresses) { continue; }
213
+ for (const addressObject of interfaceAddresses) {
214
+ const addr = addressObject.family === "IPv4"
215
+ ? addressObject.address
216
+ : addressObject.family === "IPv6"
217
+ ? `[${addressObject.address}]`
218
+ : null;
219
+ if (!addr) { continue; }
220
+ if (addr === "127.0.0.1" || addr === "[::1]") { continue; }
221
+ list.push(addr);
218
222
  }
219
223
  }
220
224
 
221
225
  return list;
222
226
  }
223
227
 
228
+ export const isLoopbackAddress = (addressWithPort) =>
229
+ addressWithPort.startsWith("127.0.0.1:") || addressWithPort.startsWith("[::1]:");
230
+
224
231
  export function isIterable(obj) {
225
232
  return obj && (typeof obj[Symbol.iterator] === "function" || typeof obj[Symbol.asyncIterator] === "function");
226
233
  }