geonix 1.23.8 → 1.30.4

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,107 @@
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
-
15
20
  #isActive = false;
16
21
  #registry = {};
22
+ #byIdentifier = new Map();
17
23
 
18
24
  constructor() {
19
25
  super();
20
26
 
21
- this.#start();
27
+ this.#start().catch((e) => logger.error("registry.start:", e));
22
28
  }
23
29
 
24
30
  async #start() {
25
31
  this.#isActive = true;
26
32
  await connection.waitUntilReady();
27
33
 
28
- this.#beaconListener();
29
- this.#garbageCollector();
34
+ this.#beaconListener().catch((e) => logger.error("registry.beaconListener:", e));
35
+ this.#garbageCollector().catch((e) => logger.error("registry.garbageCollector:", e));
30
36
  }
31
37
 
32
- async stop() {
38
+ /**
39
+ * Stops the beacon listener and garbage collector.
40
+ * @returns {void}
41
+ */
42
+ stop() {
33
43
  this.#isActive = false;
34
44
  }
35
45
 
46
+ async #checkHealth(address, addresses, onFirstHealthy, timeout = 500) {
47
+ try {
48
+ const result = await (await fetchWithTimeout(`http://${address}/!!_gx/health`, {}, timeout)).json();
49
+
50
+ if (result.status === "healthy") {
51
+ addresses.push(address);
52
+ onFirstHealthy();
53
+ return true;
54
+ }
55
+ } catch {
56
+ // ignore errors
57
+ }
58
+
59
+ return false;
60
+ }
61
+
62
+ async #registerService(data) {
63
+ if (data.a?.length > 0) {
64
+ const allAddresses = data.a;
65
+ data.a = [];
66
+
67
+ let { promise: promiseFirst, resolve: resolveFirst } = Promise.withResolvers();
68
+ let firstFound = false;
69
+ const onFirstHealthy = () => {
70
+ if (!firstFound) {
71
+ firstFound = true;
72
+ 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(() =>
80
+ resolveFirst(),
81
+ );
82
+
83
+ try {
84
+ await withTimeout(promiseFirst, 5000);
85
+ } catch {
86
+ // safety net only — should not normally be reached
87
+ }
88
+ }
89
+
90
+ this.#registry[data.i] = {
91
+ ...data,
92
+ timeout: Date.now() + REGISTRY_ENTRY_TIMEOUT,
93
+ };
94
+
95
+ const nameVersion = `${data.n}@${data.v}`;
96
+ if (!this.#byIdentifier.has(nameVersion)) {
97
+ this.#byIdentifier.set(nameVersion, new Set());
98
+ }
99
+ this.#byIdentifier.get(nameVersion).add(data.i);
100
+ this.#byIdentifier.set(data.i, new Set([data.i]));
101
+
102
+ this.emit("added", this.#registry[data.i]);
103
+ }
104
+
36
105
  async #beaconListener() {
37
106
  const subscription = await connection.subscribe("gx2.beacon");
38
107
 
@@ -41,19 +110,23 @@ class Registry extends EventEmitter {
41
110
 
42
111
  const exists = this.#registry[data.i] !== undefined;
43
112
 
44
- // if this is a short beacon, request full service info
113
+ // if this is a short beacon, request full service info on first discovery only
45
114
  if (!data.n) {
115
+ if (exists) {
116
+ this.#registry[data.i].timeout = Date.now() + REGISTRY_ENTRY_TIMEOUT;
117
+ continue;
118
+ }
46
119
  data = await directRequest(data.i, "$getServiceInfo");
47
120
  }
48
121
 
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]);
122
+ // known service — just refresh timeout, no re-probing
123
+ if (exists) {
124
+ this.#registry[data.i].timeout = Date.now() + REGISTRY_ENTRY_TIMEOUT;
125
+ continue;
56
126
  }
127
+
128
+ // new service — dispatch registration without awaiting so the beacon loop stays unblocked
129
+ this.#registerService(data).catch((e) => logger.error("registry.registerService:", e));
57
130
  }
58
131
  }
59
132
 
@@ -69,18 +142,59 @@ class Registry extends EventEmitter {
69
142
  if (now > entry.timeout) {
70
143
  delete this.#registry[identifier];
71
144
 
145
+ const nameVersion = `${entry.n}@${entry.v}`;
146
+ const set = this.#byIdentifier.get(nameVersion);
147
+ if (set) {
148
+ set.delete(entry.i);
149
+ if (set.size === 0) {
150
+ this.#byIdentifier.delete(nameVersion);
151
+ }
152
+ }
153
+ this.#byIdentifier.delete(entry.i);
154
+
72
155
  this.emit("removed", entry);
73
156
  }
74
157
  }
75
158
 
76
- await sleep(500);
159
+ await sleep(GARBAGE_COLLECTOR_INTERVAL);
77
160
  }
78
161
  }
79
162
 
163
+ /**
164
+ * Returns the raw registry map keyed by instance ID.
165
+ *
166
+ * @returns {Object.<string, object>} All currently live registry entries.
167
+ */
80
168
  getEntries() {
81
169
  return this.#registry;
82
170
  }
83
171
 
172
+ /**
173
+ * Returns all live registry entries that match a given identifier string
174
+ * (e.g. `"MyService@1.0.0"` or a bare instance ID).
175
+ *
176
+ * @param {string} identifier - Identifier in the form `"Name@version"` or an instance ID.
177
+ * @returns {object[]} Array of matching registry entry objects.
178
+ */
179
+ getEntriesForIdentifier(identifier) {
180
+ const ids = this.#byIdentifier.get(identifier);
181
+ if (!ids) {
182
+ return [];
183
+ }
184
+
185
+ return [...ids].map((i) => this.#registry[i]).filter(Boolean);
186
+ }
187
+
188
+ /**
189
+ * Looks up the best-matching service identifier for the given name, optional semver range,
190
+ * and optional static ID. Returns the highest-version match as a `"Name@version"` string,
191
+ * or the instance ID when an exact `id` is requested.
192
+ *
193
+ * @param {string} service - Service name to look up.
194
+ * @param {string} [version] - Optional semver range (e.g. `"^1.2"`).
195
+ * @param {string} [id] - Optional static service ID for targeting a specific instance.
196
+ * @returns {string|undefined} Resolved identifier, or `undefined` if not found.
197
+ */
84
198
  getIdentifier(service, version, id) {
85
199
  let matches = [];
86
200
 
@@ -108,7 +222,11 @@ class Registry extends EventEmitter {
108
222
  return `${matches[0].n}@${matches[0].v}`;
109
223
  }
110
224
  }
111
-
112
225
  }
113
226
 
114
- export const registry = new Registry();
227
+ /**
228
+ * Singleton {@link Registry} instance shared across the process.
229
+ *
230
+ * @type {Registry}
231
+ */
232
+ export const registry = new Registry();
package/src/Remote.js CHANGED
@@ -1,12 +1,25 @@
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
- export const Remote = (service, ...context) => new Proxy({}, {
11
- get: (_target, method, _receiver) => async (...args) => Request(service, method, args, context)
12
- });
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
+ }
21
+
22
+ return async (...args) => Request(service, method, args, context);
23
+ },
24
+ },
25
+ );
package/src/Request.js CHANGED
@@ -1,105 +1,146 @@
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(
113
+ url,
114
+ {
115
+ method: "POST",
116
+ headers: { "content-type": contentType },
117
+ body: fetchBody,
118
+ },
119
+ options?.httpTimeout ?? 5000,
120
+ );
121
+ if (res.ok) {
122
+ httpResponse = _payloadKey
123
+ ? JSON.parse(decryptPayload(Buffer.from(await res.arrayBuffer())))
124
+ : await res.json();
125
+ }
126
+ } catch (e) {
127
+ logger.debug("directRequest: HTTP RPC failed, falling back to NATS", address, e.message);
128
+ }
129
+
130
+ if (httpResponse) {
131
+ if (httpResponse.e) {
132
+ throw Error(`Request: remote error: ${httpResponse.e}`);
133
+ }
134
+ if (isStream(httpResponse.r)) {
135
+ return JSON.parse(await streamToString(httpResponse.r));
136
+ }
137
+ return httpResponse.r;
138
+ }
139
+ }
140
+ }
141
+
142
+ // NATS fallback
100
143
  let response;
101
- const originator = getOriginator();
102
- let requestBegin = Date.now();
103
144
  try {
104
145
  response = await connection.request(
105
146
  `gx2.service.${hash(identifier)}`,
@@ -107,19 +148,29 @@ export async function directRequest(identifier, method, args, context, options,
107
148
  m: method,
108
149
  a: args,
109
150
  c: context,
110
- o: originator
151
+ o: originator,
111
152
  },
112
- options);
153
+ options,
154
+ );
113
155
 
114
156
  // automatically process streamed response
115
157
  if (isStream(response)) {
116
158
  response = JSON.parse(await streamToString(response));
117
159
  }
118
160
  } catch (e) {
119
- logger.debug("GxError: directRequest", inspect({
120
- originator, service: service ?? identifier, method, args, context, options,
121
- error: e, duration: Date.now() - requestBegin
122
- }));
161
+ logger.debug(
162
+ "GxError: directRequest",
163
+ inspect({
164
+ originator,
165
+ service: service ?? identifier,
166
+ method,
167
+ args,
168
+ context,
169
+ options,
170
+ error: e,
171
+ duration: Date.now() - requestBegin,
172
+ }),
173
+ );
123
174
 
124
175
  throw e;
125
176
  }
@@ -137,21 +188,23 @@ export async function directRequest(identifier, method, args, context, options,
137
188
  }
138
189
 
139
190
  /**
140
- * Publish payload to a subject
141
- *
142
- * @param {string} subject
143
- * @param {string|number} payload
191
+ * Publishes a raw payload to a NATS subject. Fire-and-forget; no reply is expected.
192
+ *
193
+ * @param {string} subject - NATS subject to publish to.
194
+ * @param {string|Buffer} payload - Raw bytes or string to send.
195
+ * @returns {Promise<void>}
144
196
  */
145
197
  export async function Publish(subject, payload) {
146
- connection.publishRaw(subject, Buffer.from(payload));
198
+ await connection.publishRaw(subject, Buffer.from(payload));
147
199
  }
148
200
 
149
201
  /**
150
- * Subscribe to subject
151
- *
152
- * @param {string} subject
153
- * @param {function} callback
154
- * @returns
202
+ * Subscribes to a NATS subject and invokes `callback` with the raw message data for each
203
+ * incoming message. Returns once the subscription is drained.
204
+ *
205
+ * @param {string} subject - NATS subject to subscribe to.
206
+ * @param {function(Buffer): void} callback - Function called with the raw message data.
207
+ * @returns {Promise<void>}
155
208
  */
156
209
  export async function Subscribe(subject, callback) {
157
210
  if (typeof callback !== "function") {
@@ -162,4 +215,4 @@ export async function Subscribe(subject, callback) {
162
215
  for await (const event of subscription) {
163
216
  callback(event.data);
164
217
  }
165
- }
218
+ }
@@ -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 };