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.
@@ -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/Logger.js CHANGED
@@ -5,12 +5,16 @@ const LEVEL = LEVELS[process.env.GX_LOG_LEVEL] ?? LEVELS.info;
5
5
  const FORMAT = process.env.GX_LOG_FORMAT === "json" ? "json" : "text";
6
6
 
7
7
  const defaultLoggerOptions = {
8
- timestamp: true
8
+ timestamp: true,
9
9
  };
10
10
 
11
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); }
12
+ if (val instanceof Error) {
13
+ return val.stack || val.message;
14
+ }
15
+ if (typeof val === "object" && val !== null) {
16
+ return JSON.stringify(val);
17
+ }
14
18
  return String(val);
15
19
  }
16
20
 
@@ -22,7 +26,6 @@ function serialize(val) {
22
26
  * The output format is controlled by `GX_LOG_FORMAT` (`text` | `json`, default `text`).
23
27
  */
24
28
  export class Logger {
25
-
26
29
  #options = defaultLoggerOptions;
27
30
  #level = LEVEL;
28
31
  #format = FORMAT;
@@ -46,10 +49,17 @@ export class Logger {
46
49
  #log(level, ...args) {
47
50
  const stream = level === "error" ? process.stderr : process.stdout;
48
51
  if (this.#format === "json") {
49
- stream.write(JSON.stringify({ time: new Date().toISOString(), level, msg: args.map(serialize).join(" ") }) + "\n");
52
+ stream.write(
53
+ JSON.stringify({ time: new Date().toISOString(), level, msg: args.map(serialize).join(" ") }) + "\n",
54
+ );
50
55
  } else {
51
56
  const ts = this.#options.timestamp ? new Date().toISOString() : undefined;
52
- stream.write([ts, TAGS[level], ...args].filter($ => $ !== undefined).map(serialize).join(" ") + "\n");
57
+ stream.write(
58
+ [ts, TAGS[level], ...args]
59
+ .filter(($) => $ !== undefined)
60
+ .map(serialize)
61
+ .join(" ") + "\n",
62
+ );
53
63
  }
54
64
  }
55
65
 
@@ -104,7 +114,6 @@ export class Logger {
104
114
  setFormat(format) {
105
115
  this.#format = format === "json" ? "json" : "text";
106
116
  }
107
-
108
117
  }
109
118
 
110
119
  /**
package/src/Registry.js CHANGED
@@ -17,7 +17,6 @@ const GARBAGE_COLLECTOR_INTERVAL = 500;
17
17
  * @extends EventEmitter
18
18
  */
19
19
  class Registry extends EventEmitter {
20
-
21
20
  #isActive = false;
22
21
  #registry = {};
23
22
  #byIdentifier = new Map();
@@ -25,15 +24,15 @@ class Registry extends EventEmitter {
25
24
  constructor() {
26
25
  super();
27
26
 
28
- this.#start().catch(e => logger.error("registry.start:", e));
27
+ this.#start().catch((e) => logger.error("registry.start:", e));
29
28
  }
30
29
 
31
30
  async #start() {
32
31
  this.#isActive = true;
33
32
  await connection.waitUntilReady();
34
33
 
35
- this.#beaconListener().catch(e => logger.error("registry.beaconListener:", e));
36
- this.#garbageCollector().catch(e => logger.error("registry.garbageCollector:", e));
34
+ this.#beaconListener().catch((e) => logger.error("registry.beaconListener:", e));
35
+ this.#garbageCollector().catch((e) => logger.error("registry.garbageCollector:", e));
37
36
  }
38
37
 
39
38
  /**
@@ -69,14 +68,17 @@ class Registry extends EventEmitter {
69
68
  let firstFound = false;
70
69
  const onFirstHealthy = () => {
71
70
  if (!firstFound) {
72
- firstFound = true; resolveFirst();
71
+ firstFound = true;
72
+ resolveFirst();
73
73
  }
74
74
  };
75
75
 
76
76
  // all checks run in parallel; background ones push into data.a,
77
77
  // which is the same array reference spread into the registry entry below
78
78
  // resolve promiseFirst when all checks are done so we don't wait on the timeout
79
- Promise.all(allAddresses.map(a => this.#checkHealth(a, data.a, onFirstHealthy))).then(() => resolveFirst());
79
+ Promise.all(allAddresses.map((a) => this.#checkHealth(a, data.a, onFirstHealthy))).then(() =>
80
+ resolveFirst(),
81
+ );
80
82
 
81
83
  try {
82
84
  await withTimeout(promiseFirst, 5000);
@@ -87,7 +89,7 @@ class Registry extends EventEmitter {
87
89
 
88
90
  this.#registry[data.i] = {
89
91
  ...data,
90
- timeout: Date.now() + REGISTRY_ENTRY_TIMEOUT
92
+ timeout: Date.now() + REGISTRY_ENTRY_TIMEOUT,
91
93
  };
92
94
 
93
95
  const nameVersion = `${data.n}@${data.v}`;
@@ -124,7 +126,7 @@ class Registry extends EventEmitter {
124
126
  }
125
127
 
126
128
  // new service — dispatch registration without awaiting so the beacon loop stays unblocked
127
- this.#registerService(data).catch(e => logger.error("registry.registerService:", e));
129
+ this.#registerService(data).catch((e) => logger.error("registry.registerService:", e));
128
130
  }
129
131
  }
130
132
 
@@ -144,7 +146,9 @@ class Registry extends EventEmitter {
144
146
  const set = this.#byIdentifier.get(nameVersion);
145
147
  if (set) {
146
148
  set.delete(entry.i);
147
- if (set.size === 0) { this.#byIdentifier.delete(nameVersion); }
149
+ if (set.size === 0) {
150
+ this.#byIdentifier.delete(nameVersion);
151
+ }
148
152
  }
149
153
  this.#byIdentifier.delete(entry.i);
150
154
 
@@ -174,8 +178,11 @@ class Registry extends EventEmitter {
174
178
  */
175
179
  getEntriesForIdentifier(identifier) {
176
180
  const ids = this.#byIdentifier.get(identifier);
177
- if (!ids) { return []; }
178
- return [...ids].map(i => this.#registry[i]).filter(Boolean);
181
+ if (!ids) {
182
+ return [];
183
+ }
184
+
185
+ return [...ids].map((i) => this.#registry[i]).filter(Boolean);
179
186
  }
180
187
 
181
188
  /**
@@ -215,7 +222,6 @@ class Registry extends EventEmitter {
215
222
  return `${matches[0].n}@${matches[0].v}`;
216
223
  }
217
224
  }
218
-
219
225
  }
220
226
 
221
227
  /**
@@ -223,4 +229,4 @@ class Registry extends EventEmitter {
223
229
  *
224
230
  * @type {Registry}
225
231
  */
226
- export const registry = new Registry();
232
+ export const registry = new Registry();
package/src/Remote.js CHANGED
@@ -10,12 +10,16 @@ import { Request } from "./Request.js";
10
10
  * @param {...any} context - Optional context values forwarded to the remote method.
11
11
  * @returns {Proxy} A proxy whose properties are async functions that call the remote service.
12
12
  */
13
- export const Remote = (service, ...context) => new Proxy({}, {
14
- get: (_target, method) => {
15
- if (typeof method !== "string" || method === "then") {
16
- return undefined;
17
- }
13
+ export const Remote = (service, ...context) =>
14
+ new Proxy(
15
+ {},
16
+ {
17
+ get: (_target, method) => {
18
+ if (typeof method !== "string" || method === "then") {
19
+ return undefined;
20
+ }
18
21
 
19
- return async (...args) => Request(service, method, args, context);
20
- }
21
- });
22
+ return async (...args) => Request(service, method, args, context);
23
+ },
24
+ },
25
+ );
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";
@@ -23,7 +23,7 @@ function waitForIdentifier(name, version, id, timeout) {
23
23
  return Promise.resolve(found);
24
24
  }
25
25
 
26
- return new Promise(resolve => {
26
+ return new Promise((resolve) => {
27
27
  const timer = setTimeout(() => {
28
28
  registry.removeListener("added", onAdded);
29
29
  resolve(null);
@@ -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 };
@@ -109,11 +112,15 @@ export async function directRequest(identifier, method, args, context, options,
109
112
 
110
113
  let httpResponse;
111
114
  try {
112
- const res = await fetchWithTimeout(url, {
113
- method: "POST",
114
- headers: { "content-type": contentType },
115
- body: fetchBody,
116
- }, options?.httpTimeout ?? 5000);
115
+ const res = await fetchWithTimeout(
116
+ url,
117
+ {
118
+ method: "POST",
119
+ headers: { "content-type": contentType },
120
+ body: fetchBody,
121
+ },
122
+ options?.httpTimeout ?? 5000,
123
+ );
117
124
  if (res.ok) {
118
125
  httpResponse = _payloadKey
119
126
  ? JSON.parse(decryptPayload(Buffer.from(await res.arrayBuffer())))
@@ -124,7 +131,9 @@ export async function directRequest(identifier, method, args, context, options,
124
131
  }
125
132
 
126
133
  if (httpResponse) {
127
- if (httpResponse.e) { throw Error(`Request: remote error: ${httpResponse.e}`); }
134
+ if (httpResponse.e) {
135
+ throw Error(`Request: remote error: ${httpResponse.e}`);
136
+ }
128
137
  if (isStream(httpResponse.r)) {
129
138
  return JSON.parse(await streamToString(httpResponse.r));
130
139
  }
@@ -142,19 +151,29 @@ export async function directRequest(identifier, method, args, context, options,
142
151
  m: method,
143
152
  a: args,
144
153
  c: context,
145
- o: originator
154
+ o: originator,
146
155
  },
147
- options);
156
+ options,
157
+ );
148
158
 
149
159
  // automatically process streamed response
150
160
  if (isStream(response)) {
151
161
  response = JSON.parse(await streamToString(response));
152
162
  }
153
163
  } catch (e) {
154
- logger.debug("GxError: directRequest", inspect({
155
- originator, service: service ?? identifier, method, args, context, options,
156
- error: e, duration: Date.now() - requestBegin
157
- }));
164
+ logger.debug(
165
+ "GxError: directRequest",
166
+ inspect({
167
+ originator,
168
+ service: service ?? identifier,
169
+ method,
170
+ args,
171
+ context,
172
+ options,
173
+ error: e,
174
+ duration: Date.now() - requestBegin,
175
+ }),
176
+ );
158
177
 
159
178
  throw e;
160
179
  }
@@ -199,4 +218,4 @@ export async function Subscribe(subject, callback) {
199
218
  for await (const event of subscription) {
200
219
  callback(event.data);
201
220
  }
202
- }
221
+ }