geonix 1.23.6 → 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,30 +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
 
60
+ #isActive = false;
53
61
  #me = {};
54
- #options = defaultServiceOptions;
62
+ #options = {};
63
+ #methodTakesContext = new WeakMap();
55
64
 
56
65
  async #start(options = {}) {
57
- this.#options = deepMerge(this.#options, options); // { ...this.#options, ...options };
66
+ this.#isActive = true;
67
+ this.#options = deepMerge({}, defaultServiceOptions, options);
58
68
 
59
69
  await webserver.waitUntilReady();
60
70
  await connection.waitUntilReady();
@@ -65,16 +75,12 @@ export class Service {
65
75
 
66
76
  // preserve order of endpoints as defined in the source
67
77
  const serviceSource = this.constructor.toString().split("\n");
68
- fields.sort((a, b, ia = -1, ib = -1) => {
69
- for (let line = 0; line < serviceSource.length; line++) {
70
- ia = serviceSource[line].includes(a) ? line : ia;
71
- }
72
-
73
- for (let line = 0; line < serviceSource.length; line++) {
74
- ib = serviceSource[line].includes(b) ? line : ib;
75
- }
76
- return ia - ib;
77
- });
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));
78
84
 
79
85
  this.#me = {
80
86
  id: options?.id,
@@ -84,7 +90,7 @@ export class Service {
84
90
  // name
85
91
  n: options.name ?? this.constructor.name,
86
92
  // version
87
- 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()}`,
88
94
  // methods
89
95
  m: fields
90
96
  .filter(methodName => !protectedMethodNames.includes(methodName))
@@ -98,19 +104,33 @@ export class Service {
98
104
  // check if method takes context as first argument
99
105
  for (let methodName of this.#me.m) {
100
106
  const method = this[methodName];
101
- method.takesContext = method.toString()?.match(/\((?<args>.*)\)/)?.groups?.args.startsWith("$");
107
+ this.#methodTakesContext.set(
108
+ method,
109
+ method.toString()?.match(/\((?<args>.*)\)/)?.groups?.args.startsWith("$")
110
+ );
102
111
  }
103
112
 
104
- this.#beacon();
105
- this.#queueListener();
106
- this.#directListener();
107
- 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));
108
124
 
109
125
  logger.info("gx.service.start", this.#me.n, this.#me.v);
110
126
 
111
127
  // execute onStart method, if present
112
128
  if (this.onStart) {
113
- this.onStart();
129
+ try {
130
+ await this.onStart();
131
+ } catch (e) {
132
+ logger.error("onStart:", e);
133
+ }
114
134
  }
115
135
  }
116
136
 
@@ -119,50 +139,29 @@ export class Service {
119
139
  * on the transport
120
140
  */
121
141
  async #beacon() {
122
- while (true) {
123
- if (this.#options.fullBeacon) {
124
- connection.publish("gx2.beacon", this.#me);
125
- } else {
126
- connection.publish("gx2.beacon", { i: this.#me.i });
127
- }
128
- await sleep(1000);
129
- }
130
- }
131
-
132
- /**
133
- * Wait and respond to remote call events (queue)
134
- */
135
- async #queueListener() {
136
- const identifier = `${this.#me.n}@${this.#me.v}`;
137
- const subscription = await connection.subscribe(`gx2.service.${hash(identifier)}`, { queue: identifier });
138
-
139
- for await (let event of subscription) {
140
- let call = decode(event.data);
141
-
142
- if (isStream(call)) {
143
- call = JSON.parse(await streamToString(call));
144
- }
145
-
146
- if (call.$r && call.p) { this.#onCall(call.p, (json) => connection.publish(call.$r, json)); }
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);
147
146
  }
148
147
  }
149
148
 
150
- /**
151
- * Wait and respond to remote call events (queue)
152
- */
153
- async #directListener() {
154
- const identifier = this.#me.i;
149
+ async #callListener(identifier) {
155
150
  const subscription = await connection.subscribe(`gx2.service.${hash(identifier)}`, { queue: identifier });
156
151
 
157
152
  for await (let event of subscription) {
158
- let call = decode(event.data);
159
-
160
- if (isStream(call)) {
161
- call = JSON.parse(await streamToString(call));
162
- }
163
-
164
- if (call.$r && call.p) {
165
- 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);
166
165
  }
167
166
  }
168
167
  }
@@ -175,13 +174,31 @@ export class Service {
175
174
  const endpoints = this.#me.m
176
175
  .filter(isEndpointFilter);
177
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
+
178
197
  if (!endpoints || endpoints.length === 0) {
179
198
  return;
180
199
  }
181
200
 
182
- const router = webserver.router();
183
-
184
- // setup defualt middlewares
201
+ // setup default middlewares
185
202
  if (this.#options.middleware?.json) {
186
203
  router.use(json);
187
204
  }
@@ -212,10 +229,16 @@ export class Service {
212
229
 
213
230
  switch (verb) {
214
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
+ }
215
237
  router.ws(uri, this[endpoint].bind(this));
216
238
  break;
217
239
 
218
240
  case "sub":
241
+ // SUB operates at the NATS transport level — HTTP middleware does not apply.
219
242
  this.#sub(uri, this[endpoint].bind(this));
220
243
  break;
221
244
 
@@ -235,7 +258,7 @@ export class Service {
235
258
  handler(event.data);
236
259
  }
237
260
  };
238
- processor();
261
+ processor().catch(e => logger.error("$sub.processor:", e));
239
262
  }
240
263
 
241
264
  /**
@@ -250,23 +273,24 @@ export class Service {
250
273
  const method = this[methodName];
251
274
  let _args = args;
252
275
 
253
- if (!method) {
276
+ if (!method || methodName.startsWith("$$")) {
254
277
  return respond({ e: `unknown method (${this.#me.n}.${methodName})` });
255
278
  }
256
279
 
257
280
  try {
258
281
  // inject context as first argument
259
- if (method.takesContext) {
260
- _args = [OverlayObject(context, { caller, me: this.#me }), ..._args];
282
+ if (this.#methodTakesContext.get(method)) {
283
+ _args = [OverlayObject(context ?? {}, { caller, me: this.#me }), ..._args];
261
284
  }
262
285
 
263
- 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)) });
264
288
  } catch (e) {
265
289
  respond({ e: `${EOL}${ERROR_BEGIN_DELIMITER} ${this.#me.n}${EOL}${e.stack}${EOL}${ERROR_END_DELIMITER}` });
266
290
  }
267
291
  }
268
292
 
269
- connections = new Map();
293
+ #connections = new Map();
270
294
  async $createConnection(streamId) {
271
295
  const ingress = await connection.subscribe(`gx2.stream.${streamId}.b`);
272
296
  const control = await connection.subscribe(`gx2.stream.${streamId}.c`);
@@ -274,14 +298,35 @@ export class Service {
274
298
  const client = createConnection({ port: webserver.getPort() });
275
299
  client.on("data", (chunk) => connection.publishRaw(`gx2.stream.${streamId}.a`, chunk));
276
300
  client.on("close", () => connection.unsubscribe(ingress));
301
+ client.on("error", (e) => logger.error("$createConnection.client.error:", e));
277
302
 
278
- this.connections.set(streamId, {
303
+ this.#connections.set(streamId, {
279
304
  client,
280
305
  sub: ingress
281
306
  });
282
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
+
283
327
  const incomingLoop = async () => {
284
328
  for await (const event of ingress) {
329
+ resetTimer();
285
330
  client.write(Buffer.from(event.data));
286
331
  }
287
332
  };
@@ -289,22 +334,12 @@ export class Service {
289
334
  const controlLoop = async () => {
290
335
  // wait for first control message to arrive
291
336
  await getFirstItemFromAsyncIterable(control);
292
-
293
- const _connection = this.connections.get(streamId);
294
- if (!_connection) {
295
- return;
296
- }
297
-
298
- connection.unsubscribe(ingress);
299
- connection.unsubscribe(control);
300
-
301
- _connection.client.destroy();
302
-
303
- this.connections.delete(streamId);
337
+ clearTimeout(inactivityTimer);
338
+ cleanup();
304
339
  };
305
340
 
306
- incomingLoop();
307
- controlLoop();
341
+ incomingLoop().catch(e => { logger.error("$createConnection.incomingLoop:", e); cleanup(); });
342
+ controlLoop().catch(e => logger.error("$createConnection.controlLoop:", e));
308
343
 
309
344
  return true;
310
345
  }
@@ -317,7 +352,6 @@ export class Service {
317
352
  platform: process.platform,
318
353
  arch: process.arch
319
354
  },
320
- env: process.env,
321
355
  mem: process.memoryUsage(),
322
356
  rss: process.memoryUsage.rss(),
323
357
  cpu: process.cpuUsage()
@@ -328,4 +362,12 @@ export class Service {
328
362
  return this.#me;
329
363
  }
330
364
 
365
+ $connectionCount() {
366
+ return this.#connections.size;
367
+ }
368
+
369
+ $stop() {
370
+ this.#isActive = false;
371
+ }
372
+
331
373
  }
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
  }