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/Service.js CHANGED
@@ -8,6 +8,13 @@ import express from "express";
8
8
  import { isStream, streamToString } from "./Stream.js";
9
9
  import { logger } from "./Logger.js";
10
10
  import { decode } from "./Codec.js";
11
+ import { _payloadKey, encryptPayload, decryptPayload } from "./Crypto.js";
12
+ import { rpcContext } from "./Request.js";
13
+
14
+ const BEACON_INTERVAL = 2500;
15
+ const INACTIVITY_TIMEOUT = 90_000;
16
+
17
+ const getInactivityTimeout = () => parseInt(process.env.GX_INACTIVITY_TIMEOUT) || INACTIVITY_TIMEOUT;
11
18
 
12
19
  const protectedMethodNames = ["constructor", "onStart"];
13
20
  const endpointMatcher = /^((?<options>.+)\|)?(?<verb>WS|SUB|GET|POST|PATCH|PUT|DELETE|HEAD|OPTIONS|ALL)\s(?<url>.*)/;
@@ -31,32 +38,33 @@ const defaultServiceOptions = {
31
38
  fullBeacon: true
32
39
  };
33
40
 
41
+ /**
42
+ * Base class for all Geonix services. Extend this class and call `Service.start()` to register
43
+ * the service on the NATS bus, expose HTTP endpoints, and begin sending beacons to the registry.
44
+ */
34
45
  export class Service {
35
46
 
36
- static serviceClasses = [];
37
-
38
47
  /**
39
- *
40
- * @param {ServiceOptions} options
48
+ * Creates a new instance of the subclass and starts it on the NATS bus.
49
+ *
50
+ * @param {ServiceOptions} options - Optional configuration overrides (name, version, middleware, etc.).
51
+ * @returns {void}
41
52
  */
42
53
  static start(options = {}) {
43
- if (!this.serviceClasses.includes(this.prototype.constructor.name)) {
44
- this.serviceClasses.push(this.prototype.constructor.name);
45
- }
46
-
47
54
  const instance = new this();
48
- instance.#start(options);
55
+ instance.#start(options).catch(e => logger.error("gx.service.start:", e));
49
56
  }
50
57
 
51
58
  // ---------------------------------------------------------------------------------------------
52
59
 
53
60
  #isActive = false;
54
61
  #me = {};
55
- #options = defaultServiceOptions;
62
+ #options = {};
63
+ #methodTakesContext = new WeakMap();
56
64
 
57
65
  async #start(options = {}) {
58
66
  this.#isActive = true;
59
- this.#options = deepMerge(this.#options, options); // { ...this.#options, ...options };
67
+ this.#options = deepMerge({}, defaultServiceOptions, options);
60
68
 
61
69
  await webserver.waitUntilReady();
62
70
  await connection.waitUntilReady();
@@ -67,16 +75,12 @@ export class Service {
67
75
 
68
76
  // preserve order of endpoints as defined in the source
69
77
  const serviceSource = this.constructor.toString().split("\n");
70
- fields.sort((a, b, ia = -1, ib = -1) => {
71
- for (let line = 0; line < serviceSource.length; line++) {
72
- ia = serviceSource[line].includes(a) ? line : ia;
73
- }
74
-
75
- for (let line = 0; line < serviceSource.length; line++) {
76
- ib = serviceSource[line].includes(b) ? line : ib;
77
- }
78
- return ia - ib;
79
- });
78
+ const lineMap = new Map();
79
+ for (const name of fields) {
80
+ const quoted = JSON.stringify(name);
81
+ lineMap.set(name, serviceSource.findIndex(line => line.includes(quoted)));
82
+ }
83
+ fields.sort((a, b) => lineMap.get(a) - lineMap.get(b));
80
84
 
81
85
  this.#me = {
82
86
  id: options?.id,
@@ -86,7 +90,7 @@ export class Service {
86
90
  // name
87
91
  n: options.name ?? this.constructor.name,
88
92
  // version
89
- v: process.env.VERSION || process.env.version || options?.version || this.version || `999.999.${getSecondsSinceMidnight()}`,
93
+ v: process.env.GX_VERSION || process.env.VERSION || process.env.version || options?.version || this.version || `999.999.${getSecondsSinceMidnight()}`,
90
94
  // methods
91
95
  m: fields
92
96
  .filter(methodName => !protectedMethodNames.includes(methodName))
@@ -100,19 +104,33 @@ export class Service {
100
104
  // check if method takes context as first argument
101
105
  for (let methodName of this.#me.m) {
102
106
  const method = this[methodName];
103
- method.takesContext = method.toString()?.match(/\((?<args>.*)\)/)?.groups?.args.startsWith("$");
107
+ this.#methodTakesContext.set(
108
+ method,
109
+ method.toString()?.match(/\((?<args>.*)\)/)?.groups?.args.startsWith("$")
110
+ );
104
111
  }
105
112
 
106
- this.#beacon();
107
- this.#queueListener();
108
- this.#directListener();
109
- this.#webserver();
113
+ this.#beacon()
114
+ .catch(e => logger.error("gx.beacon:", e));
115
+
116
+ this.#callListener(`${this.#me.n}@${this.#me.v}`)
117
+ .catch(e => logger.error("gx.queueListener:", e));
118
+
119
+ this.#callListener(this.#me.i)
120
+ .catch(e => logger.error("gx.directListener:", e));
121
+
122
+ this.#webserver()
123
+ .catch(e => logger.error("gx.webserver:", e));
110
124
 
111
125
  logger.info("gx.service.start", this.#me.n, this.#me.v);
112
126
 
113
127
  // execute onStart method, if present
114
128
  if (this.onStart) {
115
- this.onStart();
129
+ try {
130
+ await this.onStart();
131
+ } catch (e) {
132
+ logger.error("onStart:", e);
133
+ }
116
134
  }
117
135
  }
118
136
 
@@ -121,50 +139,29 @@ export class Service {
121
139
  * on the transport
122
140
  */
123
141
  async #beacon() {
124
- while (true) {
125
- if (this.#options.fullBeacon) {
126
- connection.publish("gx2.beacon", this.#me);
127
- } else {
128
- connection.publish("gx2.beacon", { i: this.#me.i });
129
- }
130
- await sleep(1000);
142
+ while (this.#isActive) {
143
+ const payload = this.#options.fullBeacon ? this.#me : { i: this.#me.i };
144
+ connection.publish("gx2.beacon", payload).catch(e => logger.warn("beacon.publish:", e));
145
+ await sleep(BEACON_INTERVAL);
131
146
  }
132
147
  }
133
148
 
134
- /**
135
- * Wait and respond to remote call events (queue)
136
- */
137
- async #queueListener() {
138
- const identifier = `${this.#me.n}@${this.#me.v}`;
149
+ async #callListener(identifier) {
139
150
  const subscription = await connection.subscribe(`gx2.service.${hash(identifier)}`, { queue: identifier });
140
151
 
141
152
  for await (let event of subscription) {
142
- let call = decode(event.data);
143
-
144
- if (isStream(call)) {
145
- call = JSON.parse(await streamToString(call));
146
- }
147
-
148
- if (call.$r && call.p) { this.#onCall(call.p, (json) => connection.publish(call.$r, json)); }
149
- }
150
- }
151
-
152
- /**
153
- * Wait and respond to remote call events (queue)
154
- */
155
- async #directListener() {
156
- const identifier = this.#me.i;
157
- const subscription = await connection.subscribe(`gx2.service.${hash(identifier)}`, { queue: identifier });
158
-
159
- for await (let event of subscription) {
160
- let call = decode(event.data);
161
-
162
- if (isStream(call)) {
163
- call = JSON.parse(await streamToString(call));
164
- }
165
-
166
- if (call.$r && call.p) {
167
- this.#onCall(call.p, (json) => connection.publish(call.$r, json));
153
+ try {
154
+ let call = decode(event.data);
155
+
156
+ if (isStream(call)) {
157
+ call = JSON.parse(await streamToString(call));
158
+ }
159
+
160
+ if (call.$r && call.p) {
161
+ this.#onCall(call.p, (json) => connection.publish(call.$r, json));
162
+ }
163
+ } catch (e) {
164
+ logger.warn("gx.callListener.message:", e);
168
165
  }
169
166
  }
170
167
  }
@@ -177,13 +174,31 @@ export class Service {
177
174
  const endpoints = this.#me.m
178
175
  .filter(isEndpointFilter);
179
176
 
177
+ const router = webserver.router();
178
+
179
+ // HTTP RPC endpoint — keyed by instance ID so multiple versions on the same
180
+ // host each get a unique path
181
+ router.post(`/!!_gx/rpc/${hash(this.#me.i)}`, raw, async (req, res) => {
182
+ const body = _payloadKey
183
+ ? JSON.parse(decryptPayload(req.body))
184
+ : (Buffer.isBuffer(req.body) ? JSON.parse(req.body.toString()) : req.body);
185
+ await this.#onCall(body, (result) => {
186
+ const payload = Buffer.from(JSON.stringify(result));
187
+ if (_payloadKey) {
188
+ res.set("Content-Type", "application/octet-stream");
189
+ res.send(encryptPayload(payload));
190
+ } else {
191
+ res.set("Content-Type", "application/json");
192
+ res.send(payload);
193
+ }
194
+ });
195
+ });
196
+
180
197
  if (!endpoints || endpoints.length === 0) {
181
198
  return;
182
199
  }
183
200
 
184
- const router = webserver.router();
185
-
186
- // setup defualt middlewares
201
+ // setup default middlewares
187
202
  if (this.#options.middleware?.json) {
188
203
  router.use(json);
189
204
  }
@@ -214,10 +229,16 @@ export class Service {
214
229
 
215
230
  switch (verb) {
216
231
  case "ws":
232
+ // handlersBefore run as route-scoped middleware before the upgrade;
233
+ // handlersAfter does not apply to WebSocket connections.
234
+ if (handlersBefore.length > 0) {
235
+ router.use(uri, ...handlersBefore.map(h => (...args) => h.apply(this, args)));
236
+ }
217
237
  router.ws(uri, this[endpoint].bind(this));
218
238
  break;
219
239
 
220
240
  case "sub":
241
+ // SUB operates at the NATS transport level — HTTP middleware does not apply.
221
242
  this.#sub(uri, this[endpoint].bind(this));
222
243
  break;
223
244
 
@@ -237,7 +258,7 @@ export class Service {
237
258
  handler(event.data);
238
259
  }
239
260
  };
240
- processor();
261
+ processor().catch(e => logger.error("$sub.processor:", e));
241
262
  }
242
263
 
243
264
  /**
@@ -252,23 +273,24 @@ export class Service {
252
273
  const method = this[methodName];
253
274
  let _args = args;
254
275
 
255
- if (!method) {
276
+ if (!method || methodName.startsWith("$$")) {
256
277
  return respond({ e: `unknown method (${this.#me.n}.${methodName})` });
257
278
  }
258
279
 
259
280
  try {
260
281
  // inject context as first argument
261
- if (method.takesContext) {
262
- _args = [OverlayObject(context, { caller, me: this.#me }), ..._args];
282
+ if (this.#methodTakesContext.get(method)) {
283
+ _args = [OverlayObject(context ?? {}, { caller, me: this.#me }), ..._args];
263
284
  }
264
285
 
265
- respond({ r: await method.apply(this, _args) });
286
+ const originator = `${this.#me.n}.${methodName}`;
287
+ respond({ r: await rpcContext.run({ originator }, () => method.apply(this, _args)) });
266
288
  } catch (e) {
267
289
  respond({ e: `${EOL}${ERROR_BEGIN_DELIMITER} ${this.#me.n}${EOL}${e.stack}${EOL}${ERROR_END_DELIMITER}` });
268
290
  }
269
291
  }
270
292
 
271
- connections = new Map();
293
+ #connections = new Map();
272
294
  async $createConnection(streamId) {
273
295
  const ingress = await connection.subscribe(`gx2.stream.${streamId}.b`);
274
296
  const control = await connection.subscribe(`gx2.stream.${streamId}.c`);
@@ -276,14 +298,35 @@ export class Service {
276
298
  const client = createConnection({ port: webserver.getPort() });
277
299
  client.on("data", (chunk) => connection.publishRaw(`gx2.stream.${streamId}.a`, chunk));
278
300
  client.on("close", () => connection.unsubscribe(ingress));
301
+ client.on("error", (e) => logger.error("$createConnection.client.error:", e));
279
302
 
280
- this.connections.set(streamId, {
303
+ this.#connections.set(streamId, {
281
304
  client,
282
305
  sub: ingress
283
306
  });
284
307
 
308
+ const cleanup = () => {
309
+ const _connection = this.#connections.get(streamId);
310
+ if (!_connection) {
311
+ return;
312
+ }
313
+
314
+ connection.unsubscribe(ingress);
315
+ connection.unsubscribe(control);
316
+ _connection.client.destroy();
317
+ this.#connections.delete(streamId);
318
+ };
319
+
320
+ const timeout = getInactivityTimeout();
321
+ let inactivityTimer = setTimeout(cleanup, timeout);
322
+ const resetTimer = () => {
323
+ clearTimeout(inactivityTimer);
324
+ inactivityTimer = setTimeout(cleanup, timeout);
325
+ };
326
+
285
327
  const incomingLoop = async () => {
286
328
  for await (const event of ingress) {
329
+ resetTimer();
287
330
  client.write(Buffer.from(event.data));
288
331
  }
289
332
  };
@@ -291,22 +334,12 @@ export class Service {
291
334
  const controlLoop = async () => {
292
335
  // wait for first control message to arrive
293
336
  await getFirstItemFromAsyncIterable(control);
294
-
295
- const _connection = this.connections.get(streamId);
296
- if (!_connection) {
297
- return;
298
- }
299
-
300
- connection.unsubscribe(ingress);
301
- connection.unsubscribe(control);
302
-
303
- _connection.client.destroy();
304
-
305
- this.connections.delete(streamId);
337
+ clearTimeout(inactivityTimer);
338
+ cleanup();
306
339
  };
307
340
 
308
- incomingLoop();
309
- controlLoop();
341
+ incomingLoop().catch(e => { logger.error("$createConnection.incomingLoop:", e); cleanup(); });
342
+ controlLoop().catch(e => logger.error("$createConnection.controlLoop:", e));
310
343
 
311
344
  return true;
312
345
  }
@@ -319,7 +352,6 @@ export class Service {
319
352
  platform: process.platform,
320
353
  arch: process.arch
321
354
  },
322
- env: process.env,
323
355
  mem: process.memoryUsage(),
324
356
  rss: process.memoryUsage.rss(),
325
357
  cpu: process.cpuUsage()
@@ -330,7 +362,11 @@ export class Service {
330
362
  return this.#me;
331
363
  }
332
364
 
333
- async $stop() {
365
+ $connectionCount() {
366
+ return this.#connections.size;
367
+ }
368
+
369
+ $stop() {
334
370
  this.#isActive = false;
335
371
  }
336
372
 
package/src/Stream.js CHANGED
@@ -1,22 +1,32 @@
1
1
  import { Readable } from "stream";
2
2
  import { connection } from "./Connection.js";
3
- import { getFirstItemFromAsyncIterable, getNetworkAddresses, picoid, StreamChunker } from "./Util.js";
3
+ import { fetchWithTimeout, getFirstItemFromAsyncIterable, getNetworkAddresses, picoid, StreamChunker } from "./Util.js";
4
4
  import { logger } from "./Logger.js";
5
5
  import { webserver } from "./WebServer.js";
6
6
 
7
7
  const CHUNK_SIZE = 1024 * 128;
8
+ const STREAM_TIMEOUT = 90_000;
8
9
 
9
- export const stats = {};
10
+ const getStreamTimeout = () => parseInt(process.env.GX_STREAM_TIMEOUT) || STREAM_TIMEOUT;
11
+
12
+ /**
13
+ * Map of stream IDs to their in-process {@link Readable} instances. Entries are added when a
14
+ * stream is created and removed once the stream has been consumed or the timeout expires.
15
+ *
16
+ * @type {Object.<string, import('stream').Readable>}
17
+ */
10
18
  export const activeStreams = {};
11
19
 
12
20
  /**
13
- * Converts data to stream
14
- *
15
- * @param {*} data
16
- * @param {*} automated
17
- * @returns
21
+ * Wraps data in a Geonix stream descriptor that can be sent over the transport.
22
+ * If `data` is already a stream descriptor it is returned unchanged. Buffers and strings are
23
+ * converted to a {@link Readable} automatically. The underlying data is chunked and made
24
+ * available to consumers either via HTTP (preferred) or NATS.
25
+ *
26
+ * @param {Buffer|string|import('stream').Readable|object} data - The data to stream.
27
+ * @returns {{ $: 'stream', id: string, a?: string[] }} Stream descriptor object.
18
28
  */
19
- export function Stream(data, tag = "_") {
29
+ export function Stream(data) {
20
30
  if (isStream(data)) {
21
31
  return data;
22
32
  }
@@ -34,14 +44,25 @@ export function Stream(data, tag = "_") {
34
44
  readable.pipe(transform);
35
45
  readable = transform;
36
46
 
37
- stats[tag] = stats[tag] !== undefined ? stats[tag] + 1 : 1;
38
47
  activeStreams[id] = readable;
39
48
 
40
49
  // NATS handler
41
50
  (async () => {
42
51
  const control = await connection.subscribe(`gx2.stream.${id}.a`, { max: 1 });
43
52
 
44
- const event = await getFirstItemFromAsyncIterable(control);
53
+ const event = await Promise.race([
54
+ getFirstItemFromAsyncIterable(control),
55
+ new Promise(resolve => setTimeout(() => resolve(null), getStreamTimeout()))
56
+ ]);
57
+
58
+ if (!event) {
59
+ // timed out — clean up orphaned stream
60
+ connection.unsubscribe(control);
61
+ delete activeStreams[id];
62
+ readable.destroy();
63
+ return;
64
+ }
65
+
45
66
  if (activeStreams[id] !== undefined && event.data.length === 0) {
46
67
  // remove stream from the list
47
68
  delete activeStreams[id];
@@ -50,10 +71,9 @@ export function Stream(data, tag = "_") {
50
71
  readable.on("data", chunk => connection.publishRaw(`gx2.stream.${id}.b`, chunk));
51
72
  readable.on("close", () => {
52
73
  connection.publishRaw(`gx2.stream.${id}.b`);
53
- stats[tag]--;
54
74
  });
55
75
  }
56
- })();
76
+ })().catch(e => logger.error("stream.nats.handler:", e));
57
77
 
58
78
  const result = {
59
79
  $: "stream",
@@ -69,10 +89,25 @@ export function Stream(data, tag = "_") {
69
89
  return result;
70
90
  }
71
91
 
92
+ /**
93
+ * Returns `true` if `object` is a Geonix stream descriptor.
94
+ *
95
+ * @param {*} object - Value to test.
96
+ * @returns {boolean}
97
+ */
72
98
  export function isStream(object) {
73
99
  return object && typeof object === "object" && object.$ === "stream";
74
100
  }
75
101
 
102
+ /**
103
+ * Resolves a Geonix stream descriptor to a Node.js {@link import('stream').Readable}. If the
104
+ * descriptor advertises HTTP addresses the stream is fetched over HTTP; otherwise it falls back
105
+ * to NATS. Non-descriptor values are returned as-is.
106
+ *
107
+ * @param {{ $: 'stream', id: string, a?: string[] }|any} object - Stream descriptor or plain value.
108
+ * @returns {Promise<import('stream').Readable>}
109
+ * @throws {Error} If `object` is falsy.
110
+ */
76
111
  export async function getReadable(object) {
77
112
  if (!object) {
78
113
  throw Error("Stream.getReadable: invalid object");
@@ -86,8 +121,8 @@ export async function getReadable(object) {
86
121
  if (object.a && object.a.length > 0) {
87
122
  for (const address of object.a) {
88
123
  try {
89
- const uri = `http://${address}/!!_____stream/${object.id}`;
90
- const response = await fetch(uri);
124
+ const uri = `http://${address}/!!_gx/stream/${object.id}`;
125
+ const response = await fetchWithTimeout(uri);
91
126
 
92
127
  if (response.status === 200) {
93
128
  return Readable.fromWeb(response.body);
@@ -116,7 +151,7 @@ export async function getReadable(object) {
116
151
  }
117
152
  }
118
153
  };
119
- dataHandler();
154
+ dataHandler().catch(e => { logger.error("stream.dataHandler:", e); subscription.unsubscribe(); readable.destroy(); });
120
155
 
121
156
  // kickstart remote stream with a blank message
122
157
  await connection.publishRaw(`gx2.stream.${object.id}.a`);
@@ -124,6 +159,12 @@ export async function getReadable(object) {
124
159
  return readable;
125
160
  }
126
161
 
162
+ /**
163
+ * Reads a stream (or stream descriptor) fully into memory and returns it as a {@link Buffer}.
164
+ *
165
+ * @param {import('stream').Readable|{ $: 'stream', id: string, a?: string[] }} object - Readable or stream descriptor.
166
+ * @returns {Promise<Buffer>}
167
+ */
127
168
  export async function streamToBuffer(object) {
128
169
  let readable = object;
129
170
  if (isStream(readable)) {
@@ -133,10 +174,23 @@ export async function streamToBuffer(object) {
133
174
  return Buffer.concat(await readable.toArray());
134
175
  }
135
176
 
177
+ /**
178
+ * Reads a stream (or stream descriptor) fully into memory and returns it as a string.
179
+ *
180
+ * @param {import('stream').Readable|{ $: 'stream', id: string, a?: string[] }} object - Readable or stream descriptor.
181
+ * @param {BufferEncoding} [encoding] - Character encoding passed to `Buffer.toString()`. Defaults to UTF-8.
182
+ * @returns {Promise<string>}
183
+ */
136
184
  export async function streamToString(object, encoding) {
137
185
  return (await streamToBuffer(object)).toString(encoding);
138
186
  }
139
187
 
188
+ /**
189
+ * Reads a stream (or stream descriptor) fully into memory and parses its contents as JSON.
190
+ *
191
+ * @param {import('stream').Readable|{ $: 'stream', id: string, a?: string[] }} object - Readable or stream descriptor.
192
+ * @returns {Promise<any>}
193
+ */
140
194
  export async function streamToJSON(object) {
141
195
  return JSON.parse(await streamToBuffer(object));
142
196
  }