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/LICENSE.md +1 -1
- package/README.md +348 -4
- package/exports.js +0 -2
- package/index.d.ts +292 -237
- package/package.json +11 -10
- package/src/Codec.js +20 -7
- package/src/Connection.js +94 -40
- package/src/Crypto.js +103 -0
- package/src/Gateway.js +146 -70
- package/src/Logger.js +90 -9
- package/src/Registry.js +127 -15
- package/src/Remote.js +15 -6
- package/src/Request.js +117 -80
- package/src/RequestOptions.js +11 -8
- package/src/Service.js +128 -92
- package/src/Stream.js +69 -15
- package/src/Util.js +192 -158
- package/src/WebServer.js +18 -10
- package/.claude/settings.local.json +0 -10
- package/PROJECT.md +0 -164
- package/REVIEW.md +0 -372
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
|
-
*
|
|
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 =
|
|
62
|
+
#options = {};
|
|
63
|
+
#methodTakesContext = new WeakMap();
|
|
56
64
|
|
|
57
65
|
async #start(options = {}) {
|
|
58
66
|
this.#isActive = true;
|
|
59
|
-
this.#options = deepMerge(
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
107
|
+
this.#methodTakesContext.set(
|
|
108
|
+
method,
|
|
109
|
+
method.toString()?.match(/\((?<args>.*)\)/)?.groups?.args.startsWith("$")
|
|
110
|
+
);
|
|
104
111
|
}
|
|
105
112
|
|
|
106
|
-
this.#beacon()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.#
|
|
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
|
-
|
|
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 (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* @
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
|
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}/!!
|
|
90
|
-
const response = await
|
|
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
|
}
|