geonix 1.23.8 → 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/Registry.js CHANGED
@@ -1,38 +1,105 @@
1
1
  import { connection } from "./Connection.js";
2
- import { sleep } from "./Util.js";
2
+ import { fetchWithTimeout, sleep, withTimeout } from "./Util.js";
3
3
  import semver from "semver";
4
4
  import EventEmitter from "events";
5
5
  import { directRequest } from "./Request.js";
6
6
  import { decode } from "./Codec.js";
7
+ import { logger } from "./Logger.js";
7
8
 
8
9
  const REGISTRY_ENTRY_TIMEOUT = 5000;
10
+ const GARBAGE_COLLECTOR_INTERVAL = 500;
9
11
 
10
12
  /**
11
- * Registry maintains a local list of available services and their versions.
13
+ * Maintains a local, in-process view of all services currently present on the NATS bus.
14
+ * Entries are populated from beacon messages and expire after 5 seconds of silence.
15
+ * Emits `"added"` when a new entry is registered and `"removed"` when one expires.
16
+ *
17
+ * @extends EventEmitter
12
18
  */
13
19
  class Registry extends EventEmitter {
14
20
 
15
21
  #isActive = false;
16
22
  #registry = {};
23
+ #byIdentifier = new Map();
17
24
 
18
25
  constructor() {
19
26
  super();
20
27
 
21
- this.#start();
28
+ this.#start().catch(e => logger.error("registry.start:", e));
22
29
  }
23
30
 
24
31
  async #start() {
25
32
  this.#isActive = true;
26
33
  await connection.waitUntilReady();
27
34
 
28
- this.#beaconListener();
29
- this.#garbageCollector();
35
+ this.#beaconListener().catch(e => logger.error("registry.beaconListener:", e));
36
+ this.#garbageCollector().catch(e => logger.error("registry.garbageCollector:", e));
30
37
  }
31
38
 
32
- async stop() {
39
+ /**
40
+ * Stops the beacon listener and garbage collector.
41
+ * @returns {void}
42
+ */
43
+ stop() {
33
44
  this.#isActive = false;
34
45
  }
35
46
 
47
+ async #checkHealth(address, addresses, onFirstHealthy, timeout = 500) {
48
+ try {
49
+ const result = await (await fetchWithTimeout(`http://${address}/!!_gx/health`, {}, timeout)).json();
50
+
51
+ if (result.status === "healthy") {
52
+ addresses.push(address);
53
+ onFirstHealthy();
54
+ return true;
55
+ }
56
+ } catch {
57
+ // ignore errors
58
+ }
59
+
60
+ return false;
61
+ }
62
+
63
+ async #registerService(data) {
64
+ if (data.a?.length > 0) {
65
+ const allAddresses = data.a;
66
+ data.a = [];
67
+
68
+ let { promise: promiseFirst, resolve: resolveFirst } = Promise.withResolvers();
69
+ let firstFound = false;
70
+ const onFirstHealthy = () => {
71
+ if (!firstFound) {
72
+ firstFound = true; resolveFirst();
73
+ }
74
+ };
75
+
76
+ // all checks run in parallel; background ones push into data.a,
77
+ // which is the same array reference spread into the registry entry below
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());
80
+
81
+ try {
82
+ await withTimeout(promiseFirst, 5000);
83
+ } catch {
84
+ // safety net only — should not normally be reached
85
+ }
86
+ }
87
+
88
+ this.#registry[data.i] = {
89
+ ...data,
90
+ timeout: Date.now() + REGISTRY_ENTRY_TIMEOUT
91
+ };
92
+
93
+ const nameVersion = `${data.n}@${data.v}`;
94
+ if (!this.#byIdentifier.has(nameVersion)) {
95
+ this.#byIdentifier.set(nameVersion, new Set());
96
+ }
97
+ this.#byIdentifier.get(nameVersion).add(data.i);
98
+ this.#byIdentifier.set(data.i, new Set([data.i]));
99
+
100
+ this.emit("added", this.#registry[data.i]);
101
+ }
102
+
36
103
  async #beaconListener() {
37
104
  const subscription = await connection.subscribe("gx2.beacon");
38
105
 
@@ -41,19 +108,23 @@ class Registry extends EventEmitter {
41
108
 
42
109
  const exists = this.#registry[data.i] !== undefined;
43
110
 
44
- // if this is a short beacon, request full service info
111
+ // if this is a short beacon, request full service info on first discovery only
45
112
  if (!data.n) {
113
+ if (exists) {
114
+ this.#registry[data.i].timeout = Date.now() + REGISTRY_ENTRY_TIMEOUT;
115
+ continue;
116
+ }
46
117
  data = await directRequest(data.i, "$getServiceInfo");
47
118
  }
48
119
 
49
- this.#registry[data.i] = {
50
- ...data,
51
- timeout: Date.now() + REGISTRY_ENTRY_TIMEOUT
52
- };
53
-
54
- if (!exists) {
55
- this.emit("added", this.#registry[data.i]);
120
+ // known service — just refresh timeout, no re-probing
121
+ if (exists) {
122
+ this.#registry[data.i].timeout = Date.now() + REGISTRY_ENTRY_TIMEOUT;
123
+ continue;
56
124
  }
125
+
126
+ // new service — dispatch registration without awaiting so the beacon loop stays unblocked
127
+ this.#registerService(data).catch(e => logger.error("registry.registerService:", e));
57
128
  }
58
129
  }
59
130
 
@@ -69,18 +140,54 @@ class Registry extends EventEmitter {
69
140
  if (now > entry.timeout) {
70
141
  delete this.#registry[identifier];
71
142
 
143
+ const nameVersion = `${entry.n}@${entry.v}`;
144
+ const set = this.#byIdentifier.get(nameVersion);
145
+ if (set) {
146
+ set.delete(entry.i);
147
+ if (set.size === 0) { this.#byIdentifier.delete(nameVersion); }
148
+ }
149
+ this.#byIdentifier.delete(entry.i);
150
+
72
151
  this.emit("removed", entry);
73
152
  }
74
153
  }
75
154
 
76
- await sleep(500);
155
+ await sleep(GARBAGE_COLLECTOR_INTERVAL);
77
156
  }
78
157
  }
79
158
 
159
+ /**
160
+ * Returns the raw registry map keyed by instance ID.
161
+ *
162
+ * @returns {Object.<string, object>} All currently live registry entries.
163
+ */
80
164
  getEntries() {
81
165
  return this.#registry;
82
166
  }
83
167
 
168
+ /**
169
+ * Returns all live registry entries that match a given identifier string
170
+ * (e.g. `"MyService@1.0.0"` or a bare instance ID).
171
+ *
172
+ * @param {string} identifier - Identifier in the form `"Name@version"` or an instance ID.
173
+ * @returns {object[]} Array of matching registry entry objects.
174
+ */
175
+ getEntriesForIdentifier(identifier) {
176
+ const ids = this.#byIdentifier.get(identifier);
177
+ if (!ids) { return []; }
178
+ return [...ids].map(i => this.#registry[i]).filter(Boolean);
179
+ }
180
+
181
+ /**
182
+ * Looks up the best-matching service identifier for the given name, optional semver range,
183
+ * and optional static ID. Returns the highest-version match as a `"Name@version"` string,
184
+ * or the instance ID when an exact `id` is requested.
185
+ *
186
+ * @param {string} service - Service name to look up.
187
+ * @param {string} [version] - Optional semver range (e.g. `"^1.2"`).
188
+ * @param {string} [id] - Optional static service ID for targeting a specific instance.
189
+ * @returns {string|undefined} Resolved identifier, or `undefined` if not found.
190
+ */
84
191
  getIdentifier(service, version, id) {
85
192
  let matches = [];
86
193
 
@@ -111,4 +218,9 @@ class Registry extends EventEmitter {
111
218
 
112
219
  }
113
220
 
221
+ /**
222
+ * Singleton {@link Registry} instance shared across the process.
223
+ *
224
+ * @type {Registry}
225
+ */
114
226
  export const registry = new Registry();
package/src/Remote.js CHANGED
@@ -1,12 +1,21 @@
1
1
  import { Request } from "./Request.js";
2
2
 
3
3
  /**
4
- * Create a remote function proxy
5
- *
6
- * @param {String} service
7
- * @param {...String|Stream|Object} context
8
- * @returns {String|Stream|Object}
4
+ * Creates a proxy object that forwards property-access calls to a named remote service.
5
+ * Each property access returns an async function that invokes the corresponding method on
6
+ * the remote service via {@link Request}.
7
+ *
8
+ * @param {string} service - Service name, optionally with a version range or instance ID
9
+ * (e.g. `"MyService"`, `"MyService@^1.2"`, `"MyService#<instanceId>"`).
10
+ * @param {...any} context - Optional context values forwarded to the remote method.
11
+ * @returns {Proxy} A proxy whose properties are async functions that call the remote service.
9
12
  */
10
13
  export const Remote = (service, ...context) => new Proxy({}, {
11
- get: (_target, method, _receiver) => async (...args) => Request(service, method, args, context)
14
+ get: (_target, method) => {
15
+ if (typeof method !== "string" || method === "then") {
16
+ return undefined;
17
+ }
18
+
19
+ return async (...args) => Request(service, method, args, context);
20
+ }
12
21
  });
package/src/Request.js CHANGED
@@ -1,105 +1,140 @@
1
1
  import { connection } from "./Connection.js";
2
2
  import { registry } from "./Registry.js";
3
- import { Service } from "./Service.js";
4
- import { hash, sleep } from "./Util.js";
5
- import { RequestOptionsClass } from "./RequestOptions.js";
3
+ import { fetchWithTimeout, hash } from "./Util.js";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { _requestOptionsSymbol } from "./RequestOptions.js";
6
6
  import { isStream, streamToString } from "./Stream.js";
7
7
  import { inspect } from "node:util";
8
8
  import { logger } from "./Logger.js";
9
-
10
- const REGISTRY_TIMEOUT = 300000;
11
-
12
- /**
13
- * Get full v8 stack trace
14
- * @returns
15
- */
16
- function getStack() {
17
- const oldLimit = Error.stackTraceLimit;
18
- const oldHandler = Error.prepareStackTrace;
19
- const holder = {};
20
-
21
- // set new values
22
- Error.stackTraceLimit = Infinity;
23
- Error.prepareStackTrace = (holder, stackTrace) => stackTrace;
24
-
25
- // capture stack trace
26
- Error.captureStackTrace(holder, getStack);
27
-
28
- const stack = holder.stack;
29
-
30
- // restore values
31
- Error.prepareStackTrace = oldHandler;
32
- Error.stackTraceLimit = oldLimit;
33
-
34
- return stack;
35
- }
9
+ import { _payloadKey, encryptPayload, decryptPayload } from "./Crypto.js";
36
10
 
37
11
  /**
38
- * Determine which service class is making the call.
39
- * This information is then attached to the gx2.service call for debugging and monitoring purposes.
40
- * @returns
12
+ * AsyncLocalStorage instance used to propagate the RPC call originator across async boundaries
13
+ * so that nested service calls can include accurate tracing information.
14
+ *
15
+ * @type {import('node:async_hooks').AsyncLocalStorage<{ originator: string }>}
41
16
  */
42
- function getOriginator() {
43
- const stack = getStack();
17
+ export const rpcContext = new AsyncLocalStorage();
18
+ const REGISTRY_TIMEOUT = 300000;
44
19
 
45
- for (const item of stack) {
46
- const typeName = item.getTypeName();
20
+ function waitForIdentifier(name, version, id, timeout) {
21
+ const found = registry.getIdentifier(name, version, id);
22
+ if (found) {
23
+ return Promise.resolve(found);
24
+ }
47
25
 
48
- if (Service.serviceClasses.includes(typeName)) {
49
- return `${typeName}.${item.getMethodName()}`;
26
+ return new Promise(resolve => {
27
+ const timer = setTimeout(() => {
28
+ registry.removeListener("added", onAdded);
29
+ resolve(null);
30
+ }, timeout);
31
+
32
+ function onAdded() {
33
+ const identifier = registry.getIdentifier(name, version, id);
34
+ if (identifier) {
35
+ clearTimeout(timer);
36
+ registry.removeListener("added", onAdded);
37
+ resolve(identifier);
38
+ }
50
39
  }
51
- }
40
+
41
+ registry.on("added", onAdded);
42
+ });
52
43
  }
53
44
 
54
45
  /**
55
- * Send a request to a service
56
- *
57
- * @param {string} service
58
- * @param {string} method
59
- * @param {any[]} args
60
- * @param {any[]} context
61
- * @param {object} options
62
- * @returns
46
+ * Sends a request to a named service, waiting in the registry for up to `timeout` ms if the
47
+ * service is not yet available. Tries HTTP RPC first, falls back to NATS.
48
+ *
49
+ * @param {string} service - Service name, optionally with version (`@^1.2`) or instance ID (`#<id>`).
50
+ * @param {string} method - Method name to invoke on the service.
51
+ * @param {any[]} args - Positional arguments to pass. The first argument may be a {@link RequestOptions} object.
52
+ * @param {any[]} [context] - Optional context values forwarded to the remote method.
53
+ * @param {object} [options] - Request options (e.g. `timeout`).
54
+ * @returns {Promise<any>} The value returned by the remote method.
55
+ * @throws {Error} If the service is not found within the timeout or the remote method throws.
63
56
  */
64
57
  export async function Request(service, method, args, context, options) {
65
58
  const match = /(?<name>[^@#]*)(@(?<version>[^@#]*))?(#(?<id>[^@#]*))?/.exec(service);
66
59
  const { name, version, id } = match.groups;
67
60
 
68
61
  // allow passing RequestOptions as first arg
69
- if (args?.length > 0 && args[0] instanceof RequestOptionsClass) {
70
- options = (args.shift())?.options;
62
+ if (args?.length > 0 && args[0]?.[_requestOptionsSymbol] !== undefined) {
63
+ options = args.shift()[_requestOptionsSymbol];
71
64
  }
72
65
 
73
- let identifier = null;
74
-
75
- // get service instance identifier and wait for it if it's not available
76
66
  const registryTimeout = options?.timeout ?? REGISTRY_TIMEOUT;
77
- const delay = 5;
78
- let retries = Math.floor(registryTimeout / delay);
79
- while (identifier == null && retries-- > 0) {
80
- identifier = registry.getIdentifier(name, version, id);
81
- if (!identifier) {
82
- await sleep(delay);
83
- }
67
+ const identifier = await waitForIdentifier(name, version, id, registryTimeout);
68
+
69
+ if (!identifier) {
70
+ throw Error(`Request: service "${service}" not found within ${registryTimeout}ms`);
84
71
  }
85
72
 
86
73
  return directRequest(identifier, method, args, context, options, service);
87
74
  }
88
75
 
76
+ let _httpRpcRoundRobin = 0;
77
+
89
78
  /**
90
- * Send a request to a service
91
- *
92
- * @param {string} identifier
93
- * @param {string} method
94
- * @param {any[]} args
95
- * @param {any[]} context
96
- * @param {object} options
97
- * @returns
79
+ * Sends a request directly to a resolved registry identifier without performing a registry
80
+ * lookup. Prefers HTTP RPC for single-instance services; falls back to NATS otherwise.
81
+ *
82
+ * @param {string} identifier - Registry identifier in the form `"Name@version"` or an instance ID.
83
+ * @param {string} method - Method name to invoke on the service.
84
+ * @param {any[]} [args] - Positional arguments forwarded to the remote method.
85
+ * @param {any[]} [context] - Optional context values forwarded to the remote method.
86
+ * @param {object} [options] - Request options (e.g. `timeout`, `httpTimeout`).
87
+ * @param {string} [service] - Original service string used only for error logging.
88
+ * @returns {Promise<any>} The value returned by the remote method.
89
+ * @throws {Error} If the transport returns an error or the response is invalid.
98
90
  */
99
91
  export async function directRequest(identifier, method, args, context, options, service) {
92
+ const originator = rpcContext.getStore()?.originator;
93
+ const requestBegin = Date.now();
94
+
95
+ // try HTTP RPC first if the service has a single known instance with addresses
96
+ // (multiple instances use NATS so queue-group distribution is preserved)
97
+ const entries = registry.getEntriesForIdentifier(identifier);
98
+ if (entries.length === 1) {
99
+ const addresses = entries[0].a || [];
100
+ if (addresses.length > 0) {
101
+ const address = addresses[_httpRpcRoundRobin++ % addresses.length];
102
+
103
+ const url = `http://${address}/!!_gx/rpc/${hash(entries[0].i)}`;
104
+ const rpcPayload = { m: method, a: args, c: context, o: originator };
105
+ const fetchBody = _payloadKey
106
+ ? encryptPayload(Buffer.from(JSON.stringify(rpcPayload)))
107
+ : JSON.stringify(rpcPayload);
108
+ const contentType = _payloadKey ? "application/octet-stream" : "application/json";
109
+
110
+ let httpResponse;
111
+ try {
112
+ const res = await fetchWithTimeout(url, {
113
+ method: "POST",
114
+ headers: { "content-type": contentType },
115
+ body: fetchBody,
116
+ }, options?.httpTimeout ?? 5000);
117
+ if (res.ok) {
118
+ httpResponse = _payloadKey
119
+ ? JSON.parse(decryptPayload(Buffer.from(await res.arrayBuffer())))
120
+ : await res.json();
121
+ }
122
+ } catch (e) {
123
+ logger.debug("directRequest: HTTP RPC failed, falling back to NATS", address, e.message);
124
+ }
125
+
126
+ if (httpResponse) {
127
+ if (httpResponse.e) { throw Error(`Request: remote error: ${httpResponse.e}`); }
128
+ if (isStream(httpResponse.r)) {
129
+ return JSON.parse(await streamToString(httpResponse.r));
130
+ }
131
+ return httpResponse.r;
132
+ }
133
+ }
134
+ }
135
+
136
+ // NATS fallback
100
137
  let response;
101
- const originator = getOriginator();
102
- let requestBegin = Date.now();
103
138
  try {
104
139
  response = await connection.request(
105
140
  `gx2.service.${hash(identifier)}`,
@@ -137,21 +172,23 @@ export async function directRequest(identifier, method, args, context, options,
137
172
  }
138
173
 
139
174
  /**
140
- * Publish payload to a subject
141
- *
142
- * @param {string} subject
143
- * @param {string|number} payload
175
+ * Publishes a raw payload to a NATS subject. Fire-and-forget; no reply is expected.
176
+ *
177
+ * @param {string} subject - NATS subject to publish to.
178
+ * @param {string|Buffer} payload - Raw bytes or string to send.
179
+ * @returns {Promise<void>}
144
180
  */
145
181
  export async function Publish(subject, payload) {
146
- connection.publishRaw(subject, Buffer.from(payload));
182
+ await connection.publishRaw(subject, Buffer.from(payload));
147
183
  }
148
184
 
149
185
  /**
150
- * Subscribe to subject
151
- *
152
- * @param {string} subject
153
- * @param {function} callback
154
- * @returns
186
+ * Subscribes to a NATS subject and invokes `callback` with the raw message data for each
187
+ * incoming message. Returns once the subscription is drained.
188
+ *
189
+ * @param {string} subject - NATS subject to subscribe to.
190
+ * @param {function(Buffer): void} callback - Function called with the raw message data.
191
+ * @returns {Promise<void>}
155
192
  */
156
193
  export async function Subscribe(subject, callback) {
157
194
  if (typeof callback !== "function") {
@@ -1,11 +1,14 @@
1
- export class RequestOptionsClass {
2
- options;
1
+ const OPTS = Symbol("RequestOptions");
3
2
 
4
- constructor(options) {
5
- this.options = options;
6
- }
3
+ /**
4
+ * Wraps a plain options object in a sentinel that {@link Request} can detect and extract
5
+ * when it appears as the first argument of a service method call.
6
+ *
7
+ * @param {{ timeout?: number, httpTimeout?: number }} options - Request options to attach.
8
+ * @returns {{ [symbol]: object }} Sentinel object consumed by {@link Request}.
9
+ */
10
+ export function RequestOptions(options) {
11
+ return { [OPTS]: options };
7
12
  }
8
13
 
9
- export const RequestOptions = (options) => {
10
- return new RequestOptionsClass(options);
11
- };
14
+ export { OPTS as _requestOptionsSymbol };