geonix 1.30.4 → 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 +9 -1
- package/package.json +1 -1
- package/src/Connection.js +13 -0
- package/src/Gateway.js +10 -4
- package/src/LocalBus.js +190 -0
- package/src/Request.js +5 -2
- package/src/Util.js +19 -12
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
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";
|
|
@@ -397,9 +397,15 @@ export class Gateway {
|
|
|
397
397
|
const endpoints = [];
|
|
398
398
|
|
|
399
399
|
for (let { entry, proxy } of entries) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
}
|
|
403
409
|
|
|
404
410
|
if (!backendAddr) {
|
|
405
411
|
continue;
|
package/src/LocalBus.js
ADDED
|
@@ -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/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
|
|
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/Util.js
CHANGED
|
@@ -203,24 +203,31 @@ export async function getFirstItemFromAsyncIterable(asyncIterable) {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
export function getNetworkAddresses() {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
}
|