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