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/LICENSE.md +1 -1
- package/README.md +348 -4
- package/exports.js +0 -2
- package/index.d.ts +292 -237
- package/package.json +12 -11
- package/src/Codec.js +21 -8
- package/src/Connection.js +164 -53
- package/src/Crypto.js +117 -0
- package/src/Gateway.js +172 -87
- package/src/Logger.js +101 -11
- package/src/Registry.js +136 -18
- package/src/Remote.js +21 -8
- package/src/Request.js +140 -87
- package/src/RequestOptions.js +11 -8
- package/src/Service.js +176 -113
- package/src/Stream.js +78 -18
- package/src/Util.js +229 -188
- package/src/WebServer.js +29 -22
- package/.claude/settings.local.json +0 -10
- package/.vscode/settings.json +0 -11
- package/PROJECT.md +0 -164
- package/REVIEW.md +0 -372
package/src/Service.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { connection } from "./Connection.js";
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
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 =
|
|
71
|
+
#options = {};
|
|
72
|
+
#methodTakesContext = new WeakMap();
|
|
56
73
|
|
|
57
74
|
async #start(options = {}) {
|
|
58
75
|
this.#isActive = true;
|
|
59
|
-
this.#options = deepMerge(
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
this.#
|
|
109
|
-
|
|
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
|
-
|
|
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 (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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));
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
this.#
|
|
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
|
-
|
|
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,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
|
|
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()
|
|
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}/!!
|
|
90
|
-
const response = await
|
|
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
|
}
|