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/LICENSE.md +1 -1
- package/README.md +348 -4
- package/exports.js +0 -2
- package/index.d.ts +292 -237
- package/package.json +11 -8
- package/src/Codec.js +20 -7
- package/src/Connection.js +94 -40
- package/src/Crypto.js +103 -0
- package/src/Gateway.js +155 -70
- package/src/Logger.js +90 -9
- package/src/Registry.js +133 -15
- package/src/Remote.js +15 -6
- package/src/Request.js +117 -80
- package/src/RequestOptions.js +11 -8
- package/src/Service.js +133 -91
- package/src/Stream.js +69 -15
- package/src/Util.js +196 -158
- package/src/WebServer.js +18 -10
- package/test/context.js +0 -35
- package/test/delayedStart.js +0 -24
- package/test/gateway.js +0 -34
- package/test/middleware.js +0 -24
- package/test/package.json +0 -16
- package/test/pubsub.js +0 -29
- package/test/simple.js +0 -29
- package/test/static/index.html +0 -1
- package/test/stream.js +0 -43
- package/test/upload.js +0 -34
- package/test/ws_auth.js +0 -21
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
|
-
*
|
|
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 =
|
|
62
|
+
#options = {};
|
|
63
|
+
#methodTakesContext = new WeakMap();
|
|
55
64
|
|
|
56
65
|
async #start(options = {}) {
|
|
57
|
-
this.#
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
107
|
+
this.#methodTakesContext.set(
|
|
108
|
+
method,
|
|
109
|
+
method.toString()?.match(/\((?<args>.*)\)/)?.groups?.args.startsWith("$")
|
|
110
|
+
);
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
this.#beacon()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
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));
|
|
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
|
-
|
|
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 (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|