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/Gateway.js
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import { connection } from "./Connection.js";
|
|
2
2
|
import { registry } from "./Registry.js";
|
|
3
|
-
import { cleanupWebsocketUrl, createTCPServer, GeonixVersion, picoid, proxyHttp, sleep } from "./Util.js";
|
|
3
|
+
import { cleanupWebsocketUrl, createTCPServer, GeonixVersion, hash, picoid, proxyHttp, sleep } from "./Util.js";
|
|
4
4
|
import express, { Router } from "express";
|
|
5
5
|
import { Request } from "./Request.js";
|
|
6
|
-
import { HEALTH_CHECK_ENDPOINT } from "./WebServer.js";
|
|
7
6
|
import expressWs from "express-ws";
|
|
8
|
-
import querystring from "querystring";
|
|
9
7
|
import semver from "semver";
|
|
10
8
|
import { WebSocket } from "ws";
|
|
11
9
|
import { logger } from "./Logger.js";
|
|
12
10
|
|
|
13
|
-
const DEBUG_ENDPOINT = "/lZ6jD2eC3iP0zB3jJ1yJ9pM8gG3yI3vS";
|
|
14
11
|
const endpointMatcher = /^((?<options>.+)\|)?(?<verb>WS|GET|POST|PATCH|PUT|DELETE|HEAD|OPTIONS|ALL)\s(?<url>.*)/;
|
|
15
12
|
|
|
13
|
+
const MAX_SESSIONS = 16384;
|
|
14
|
+
|
|
15
|
+
// Real client IP — checks well-known CDN/proxy headers before falling back to socket address
|
|
16
|
+
function getClientIp(req) {
|
|
17
|
+
const header =
|
|
18
|
+
req.headers["cf-connecting-ip"] || // Cloudflare
|
|
19
|
+
req.headers["true-client-ip"] || // Cloudflare Enterprise / Akamai
|
|
20
|
+
req.headers["x-real-ip"] || // nginx
|
|
21
|
+
req.headers["x-forwarded-for"]; // standard (may be comma-separated list)
|
|
22
|
+
|
|
23
|
+
if (header) {
|
|
24
|
+
return header.split(",")[0].trim();
|
|
25
|
+
}
|
|
26
|
+
return req.socket?.remoteAddress || "unknown";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Deterministic index into an array based on a string key
|
|
30
|
+
function stableIndex(str, length) {
|
|
31
|
+
return parseInt(hash(str).slice(0, 8), 16) % length;
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
const requestLogger = (req, res, next) => {
|
|
17
|
-
logger.
|
|
35
|
+
logger.debug(`HTTP ${req.method} ${req.path}`);
|
|
18
36
|
|
|
19
37
|
next();
|
|
20
38
|
};
|
|
@@ -23,16 +41,26 @@ const stats = {
|
|
|
23
41
|
requests: 0,
|
|
24
42
|
proxied: 0,
|
|
25
43
|
proxied_over_nats: 0,
|
|
26
|
-
debug_requests: 0
|
|
44
|
+
debug_requests: 0,
|
|
27
45
|
};
|
|
28
46
|
|
|
29
47
|
const defaultOpts = {
|
|
30
|
-
beforeRequest: (_req, _res) => {
|
|
31
|
-
afterRequest: (_req, _res) => {
|
|
48
|
+
beforeRequest: (_req, _res) => {},
|
|
49
|
+
afterRequest: (_req, _res) => {},
|
|
32
50
|
};
|
|
33
51
|
|
|
52
|
+
/**
|
|
53
|
+
* HTTP gateway that automatically discovers Geonix services via the registry and reverse-proxies
|
|
54
|
+
* incoming HTTP and WebSocket requests to the appropriate service instance. Routes are rebuilt
|
|
55
|
+
* dynamically as services join and leave the bus.
|
|
56
|
+
*/
|
|
34
57
|
export class Gateway {
|
|
35
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Creates and starts a new Gateway instance.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [opts] - Gateway options (e.g. `cors`, `beforeRequest`, `afterRequest`).
|
|
62
|
+
* @returns {Gateway}
|
|
63
|
+
*/
|
|
36
64
|
static start(opts) {
|
|
37
65
|
return new Gateway(opts);
|
|
38
66
|
}
|
|
@@ -46,16 +74,19 @@ export class Gateway {
|
|
|
46
74
|
|
|
47
75
|
#rebuildRouter = false;
|
|
48
76
|
#buildRouterRunning = false;
|
|
77
|
+
#rebuildRouterInterval = null;
|
|
49
78
|
#endpoints = [];
|
|
50
79
|
|
|
51
80
|
#registry = {};
|
|
81
|
+
#server;
|
|
82
|
+
#sessions = new Map(); // _gx token → backend address
|
|
52
83
|
|
|
53
84
|
constructor(opts) {
|
|
54
85
|
expressWs(this.#api);
|
|
55
86
|
|
|
56
87
|
this.#opts = { ...this.#opts, ...opts };
|
|
57
88
|
|
|
58
|
-
this.#start();
|
|
89
|
+
this.#start().catch((e) => logger.error("gx.gateway.start:", e));
|
|
59
90
|
}
|
|
60
91
|
|
|
61
92
|
async #start(port = 8080) {
|
|
@@ -63,36 +94,59 @@ export class Gateway {
|
|
|
63
94
|
|
|
64
95
|
await connection.waitUntilReady();
|
|
65
96
|
|
|
66
|
-
this.#port = process.env.PORT || port;
|
|
67
|
-
this.#api.listen(this.#port);
|
|
68
|
-
|
|
69
|
-
logger.debug(`geonix.gateway: listening on http://0.0.0.0:${this.#port}`);
|
|
97
|
+
this.#port = process.env.GX_PORT || process.env.PORT || port;
|
|
70
98
|
|
|
71
99
|
// logging
|
|
72
100
|
this.#api.use(requestLogger);
|
|
73
101
|
|
|
74
102
|
// cors
|
|
75
103
|
this.#api.use((req, res, next) => {
|
|
104
|
+
const cors = this.#opts.cors;
|
|
76
105
|
const origin = req.headers["origin"];
|
|
77
106
|
const allMethods = "GET,PUT,POST,DELETE,OPTIONS,HEAD";
|
|
78
|
-
const allHeaders = "*";
|
|
79
107
|
const requestMethod = req.headers["access-control-request-method"];
|
|
80
108
|
const requestHeaders = req.headers["access-control-request-headers"];
|
|
81
109
|
|
|
82
|
-
res.set("Access-Control-Allow-Credentials", "true");
|
|
83
|
-
res.set("Access-Control-Allow-Origin", origin || "*");
|
|
84
|
-
res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
|
|
85
110
|
res.set("Allow", requestMethod || allMethods);
|
|
86
|
-
|
|
111
|
+
|
|
112
|
+
if (!cors) {
|
|
113
|
+
// off — no CORS headers
|
|
114
|
+
} else if (cors === "*") {
|
|
115
|
+
// wildcard — all origins, no credentials
|
|
116
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
117
|
+
res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
|
|
118
|
+
res.set("Access-Control-Allow-Headers", requestHeaders || "*");
|
|
119
|
+
} else if (Array.isArray(cors)) {
|
|
120
|
+
// allowlist — credentials only for listed origins
|
|
121
|
+
if (origin && cors.includes(origin)) {
|
|
122
|
+
res.set("Access-Control-Allow-Credentials", "true");
|
|
123
|
+
res.set("Access-Control-Allow-Origin", origin);
|
|
124
|
+
res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
|
|
125
|
+
res.set("Access-Control-Allow-Headers", requestHeaders || "*");
|
|
126
|
+
}
|
|
127
|
+
} else if (cors === true) {
|
|
128
|
+
// open — reflect any origin with credentials
|
|
129
|
+
if (origin) {
|
|
130
|
+
res.set("Access-Control-Allow-Credentials", "true");
|
|
131
|
+
res.set("Access-Control-Allow-Origin", origin);
|
|
132
|
+
res.set("Access-Control-Allow-Methods", requestMethod || allMethods);
|
|
133
|
+
res.set("Access-Control-Allow-Headers", requestHeaders || "*");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
87
136
|
|
|
88
137
|
next();
|
|
89
138
|
});
|
|
90
139
|
|
|
91
|
-
// debug router (only
|
|
92
|
-
if (process.env.
|
|
93
|
-
this.#api.use(
|
|
140
|
+
// debug router (only mounted when GX_DEBUG_ENDPOINT is set)
|
|
141
|
+
if (process.env.GX_DEBUG_ENDPOINT) {
|
|
142
|
+
this.#api.use(process.env.GX_DEBUG_ENDPOINT, this.#debugRouter());
|
|
94
143
|
}
|
|
95
144
|
|
|
145
|
+
// block internal gx namespace from being proxied to external clients
|
|
146
|
+
this.#api.all("/!!_gx/*", (req, res) => {
|
|
147
|
+
res.status(403).json({ error: 403, source: "gw" });
|
|
148
|
+
});
|
|
149
|
+
|
|
96
150
|
this.#api.use((req, res, next) => {
|
|
97
151
|
if (this.#opts.beforeRequest) {
|
|
98
152
|
this.#opts.beforeRequest(req, res);
|
|
@@ -126,11 +180,14 @@ export class Gateway {
|
|
|
126
180
|
this.#api.all("*", (req, res) => {
|
|
127
181
|
res.status(404).send({
|
|
128
182
|
error: 404,
|
|
129
|
-
source: "gw"
|
|
183
|
+
source: "gw",
|
|
130
184
|
});
|
|
131
185
|
});
|
|
132
186
|
|
|
133
|
-
|
|
187
|
+
this.#server = this.#api.listen(this.#port);
|
|
188
|
+
logger.info(`geonix.gateway: listening on http://0.0.0.0:${this.#port}`);
|
|
189
|
+
|
|
190
|
+
this.#rebuildRouterInterval = setInterval(() => {
|
|
134
191
|
if (this.#rebuildRouter) {
|
|
135
192
|
this.#buildRouter();
|
|
136
193
|
}
|
|
@@ -141,7 +198,7 @@ export class Gateway {
|
|
|
141
198
|
// send keeplive to check if connection is still alive
|
|
142
199
|
await connection.publish("gx.gateway.keepalive", Date.now());
|
|
143
200
|
|
|
144
|
-
await this.#
|
|
201
|
+
await this.#handleAddedServices();
|
|
145
202
|
await this.#handleRemovedServices();
|
|
146
203
|
await sleep(1000);
|
|
147
204
|
|
|
@@ -153,42 +210,31 @@ export class Gateway {
|
|
|
153
210
|
}
|
|
154
211
|
}
|
|
155
212
|
|
|
156
|
-
|
|
213
|
+
this.#server?.close();
|
|
157
214
|
|
|
158
215
|
// terminate process
|
|
159
|
-
logger.
|
|
216
|
+
logger.info("geonix.gateway: stopped");
|
|
160
217
|
}
|
|
161
218
|
|
|
162
|
-
async #
|
|
219
|
+
async #handleAddedServices() {
|
|
163
220
|
let entries = Object.values(registry.getEntries());
|
|
164
221
|
|
|
165
222
|
const processEntry = async (entry) => {
|
|
166
|
-
|
|
223
|
+
const existing = this.#registry[entry.i];
|
|
167
224
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for (let address of entry.a) {
|
|
174
|
-
try {
|
|
175
|
-
const ac = new AbortController();
|
|
176
|
-
const timeout = setTimeout(() => ac.abort(), 500);
|
|
177
|
-
const result = await (await fetch(`http://${address}${HEALTH_CHECK_ENDPOINT}`, { signal: ac.signal })).json();
|
|
178
|
-
clearTimeout(timeout);
|
|
179
|
-
if (result.status === "healthy" && result.services?.includes(entry.n)) {
|
|
180
|
-
backend = address;
|
|
181
|
-
logger.info(`${entry.n}@${entry.v} (#${entry.i}) directly reachable @ ${address}`);
|
|
182
|
-
break;
|
|
183
|
-
}
|
|
184
|
-
} catch {
|
|
185
|
-
// silently ignore errors
|
|
186
|
-
}
|
|
225
|
+
if (existing !== undefined) {
|
|
226
|
+
// trigger rebuild if a direct entry has gained new addresses since last seen
|
|
227
|
+
if (!existing.proxy && entry.a?.length !== existing.knownAddressCount) {
|
|
228
|
+
existing.knownAddressCount = entry.a.length;
|
|
229
|
+
this.#rebuildRouter = true;
|
|
187
230
|
}
|
|
231
|
+
return false;
|
|
188
232
|
}
|
|
189
233
|
|
|
234
|
+
logger.info(`gateway.onServiceAdded: ${entry.n}@${entry.v} (#${entry.i})`);
|
|
235
|
+
|
|
190
236
|
let proxy;
|
|
191
|
-
if (!
|
|
237
|
+
if (!entry.a?.length) {
|
|
192
238
|
// create proxy over nats
|
|
193
239
|
proxy = await createTCPServer(async (client) => {
|
|
194
240
|
const streamId = picoid();
|
|
@@ -200,16 +246,15 @@ export class Gateway {
|
|
|
200
246
|
logger.error("nats.proxy.error", e);
|
|
201
247
|
client.destroy();
|
|
202
248
|
}
|
|
203
|
-
}
|
|
204
|
-
backend = `127.0.0.1:${proxy.port}`;
|
|
249
|
+
});
|
|
205
250
|
}
|
|
206
251
|
|
|
207
|
-
this.#registry[entry.i] = { entry, proxy,
|
|
252
|
+
this.#registry[entry.i] = { entry, proxy, knownAddressCount: entry.a?.length ?? 0 };
|
|
208
253
|
|
|
209
254
|
return true;
|
|
210
255
|
};
|
|
211
256
|
|
|
212
|
-
entries = (await Promise.all(entries.map(processEntry))).filter(result => result === true);
|
|
257
|
+
entries = (await Promise.all(entries.map(processEntry))).filter((result) => result === true);
|
|
213
258
|
|
|
214
259
|
if (entries.length > 0) {
|
|
215
260
|
this.#rebuildRouter = true;
|
|
@@ -241,7 +286,7 @@ export class Gateway {
|
|
|
241
286
|
});
|
|
242
287
|
|
|
243
288
|
router.get("/services", (req, res) => {
|
|
244
|
-
const services = Object.values(registry.getEntries()).map(e =>
|
|
289
|
+
const services = Object.values(registry.getEntries()).map((e) => `${e.n}@${e.v}`);
|
|
245
290
|
services.sort();
|
|
246
291
|
res.send(services);
|
|
247
292
|
});
|
|
@@ -268,12 +313,11 @@ export class Gateway {
|
|
|
268
313
|
node: {
|
|
269
314
|
version: process.version,
|
|
270
315
|
platform: process.platform,
|
|
271
|
-
arch: process.arch
|
|
316
|
+
arch: process.arch,
|
|
272
317
|
},
|
|
273
|
-
env: process.env,
|
|
274
318
|
mem: process.memoryUsage(),
|
|
275
319
|
rss: process.memoryUsage.rss(),
|
|
276
|
-
cpu: process.cpuUsage()
|
|
320
|
+
cpu: process.cpuUsage(),
|
|
277
321
|
});
|
|
278
322
|
});
|
|
279
323
|
|
|
@@ -293,42 +337,46 @@ export class Gateway {
|
|
|
293
337
|
connection.unsubscribe(ingress);
|
|
294
338
|
connection.publish(`gx2.stream.${streamId}.c`, Buffer.from("end"));
|
|
295
339
|
});
|
|
296
|
-
client.on("error", (
|
|
297
|
-
|
|
340
|
+
client.on("error", (e) => {
|
|
341
|
+
logger.debug("nats.proxy.client.error:", e);
|
|
298
342
|
});
|
|
299
343
|
|
|
300
344
|
const dataLoop = async () => {
|
|
301
|
-
for await (const event of ingress) {
|
|
345
|
+
for await (const event of ingress) {
|
|
346
|
+
client.write(event.data);
|
|
347
|
+
}
|
|
302
348
|
};
|
|
303
349
|
|
|
304
|
-
dataLoop();
|
|
350
|
+
dataLoop().catch((e) => logger.error("nats.proxy.dataLoop:", e));
|
|
305
351
|
}
|
|
306
352
|
}
|
|
307
353
|
|
|
308
354
|
/**
|
|
309
355
|
* Proxies websocket connection
|
|
310
|
-
*
|
|
311
|
-
* @param {string} target
|
|
312
|
-
* @param {Readable} inbound
|
|
313
|
-
* @param {Request} req
|
|
356
|
+
*
|
|
357
|
+
* @param {string} target
|
|
358
|
+
* @param {Readable} inbound
|
|
359
|
+
* @param {Request} req
|
|
314
360
|
*/
|
|
315
|
-
#
|
|
361
|
+
#proxyWebsocket(target, inbound, req) {
|
|
316
362
|
try {
|
|
317
|
-
const backend = new WebSocket(target, {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
363
|
+
const backend = new WebSocket(target, { headers: { ...req.headers } });
|
|
364
|
+
|
|
365
|
+
backend.on("error", (e) => {
|
|
366
|
+
logger.error("proxy.ws.backend.error:", e);
|
|
367
|
+
inbound.close();
|
|
321
368
|
});
|
|
322
369
|
|
|
323
370
|
backend.on("open", () => {
|
|
324
371
|
backend.on("message", (data, isBinary) => inbound.send(isBinary ? data : data.toString()));
|
|
325
|
-
inbound.on("message", data => backend.send(data));
|
|
372
|
+
inbound.on("message", (data) => backend.send(data));
|
|
326
373
|
|
|
327
374
|
backend.on("close", () => inbound.close());
|
|
328
375
|
inbound.on("close", () => backend.close());
|
|
329
376
|
});
|
|
330
377
|
} catch (e) {
|
|
331
|
-
logger.error(e);
|
|
378
|
+
logger.error("proxy.ws.backend.error:", e);
|
|
379
|
+
inbound.close();
|
|
332
380
|
}
|
|
333
381
|
}
|
|
334
382
|
|
|
@@ -348,14 +396,27 @@ export class Gateway {
|
|
|
348
396
|
|
|
349
397
|
const endpoints = [];
|
|
350
398
|
|
|
351
|
-
for (let { entry,
|
|
399
|
+
for (let { entry, proxy } of entries) {
|
|
400
|
+
const backendAddr = proxy
|
|
401
|
+
? `127.0.0.1:${proxy.port}`
|
|
402
|
+
: entry.a?.[Math.floor(Math.random() * entry.a.length)];
|
|
403
|
+
|
|
404
|
+
if (!backendAddr) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
352
408
|
// generate global endpoint list
|
|
353
409
|
for (let e of entry.m) {
|
|
354
|
-
|
|
355
|
-
|
|
410
|
+
const endpointMatch = endpointMatcher.exec(e);
|
|
411
|
+
if (endpointMatch) {
|
|
412
|
+
const endpoint = endpointMatch.groups;
|
|
413
|
+
const parsed = endpoint.options
|
|
414
|
+
? Object.fromEntries(new URLSearchParams(endpoint.options))
|
|
415
|
+
: {};
|
|
416
|
+
const parsedOrder = parseInt(parsed.order, 10);
|
|
356
417
|
let options = {
|
|
357
|
-
|
|
358
|
-
|
|
418
|
+
...parsed,
|
|
419
|
+
order: Number.isNaN(parsedOrder) ? 100 : parsedOrder,
|
|
359
420
|
};
|
|
360
421
|
|
|
361
422
|
try {
|
|
@@ -364,7 +425,7 @@ export class Gateway {
|
|
|
364
425
|
version: semver.coerce(entry.v).version,
|
|
365
426
|
options,
|
|
366
427
|
endpoint,
|
|
367
|
-
backend: [
|
|
428
|
+
backend: [backendAddr],
|
|
368
429
|
});
|
|
369
430
|
} catch (e) {
|
|
370
431
|
logger.error("gateway.buildRouter.error:", entry);
|
|
@@ -381,7 +442,10 @@ export class Gateway {
|
|
|
381
442
|
const url = `${endpoints[index].endpoint.verb} ${endpoints[index].endpoint.url}`;
|
|
382
443
|
|
|
383
444
|
for (let n = 0; n < index; n++) {
|
|
384
|
-
if (
|
|
445
|
+
if (
|
|
446
|
+
`${endpoints[n].endpoint.verb} ${endpoints[n].endpoint.url}` === url &&
|
|
447
|
+
endpoints[n].version === version
|
|
448
|
+
) {
|
|
385
449
|
endpoints[n].backend = endpoints[n].backend.concat(endpoints[index].backend);
|
|
386
450
|
endpoints.splice(index, 1);
|
|
387
451
|
break;
|
|
@@ -391,28 +455,45 @@ export class Gateway {
|
|
|
391
455
|
|
|
392
456
|
// sort endpoints by order, if there is one
|
|
393
457
|
endpoints.sort((a, b) => semver.rcompare(a.version, b.version));
|
|
394
|
-
endpoints.sort((a, b) =>
|
|
458
|
+
endpoints.sort((a, b) => a.options.order - b.options.order);
|
|
395
459
|
|
|
396
460
|
this.#endpoints = endpoints;
|
|
397
461
|
|
|
398
462
|
// build the router
|
|
399
463
|
for (let endpoint of endpoints) {
|
|
400
464
|
let { verb, url: uri } = endpoint.endpoint;
|
|
401
|
-
let backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
|
|
402
|
-
|
|
403
465
|
verb = verb.toLowerCase();
|
|
404
466
|
|
|
405
467
|
if (verb === "ws") {
|
|
406
468
|
router.ws(uri, (ws, req) => {
|
|
407
|
-
|
|
469
|
+
const backends = endpoint.backend;
|
|
470
|
+
const gxToken = req.query?._gx;
|
|
471
|
+
let selectedBackend;
|
|
472
|
+
|
|
473
|
+
if (gxToken) {
|
|
474
|
+
const stored = this.#sessions.get(gxToken);
|
|
475
|
+
if (stored && backends.includes(stored)) {
|
|
476
|
+
selectedBackend = stored;
|
|
477
|
+
} else {
|
|
478
|
+
// backend gone or first time seeing this token
|
|
479
|
+
selectedBackend = backends[stableIndex(getClientIp(req), backends.length)];
|
|
480
|
+
if (this.#sessions.size >= MAX_SESSIONS) {
|
|
481
|
+
this.#sessions.delete(this.#sessions.keys().next().value);
|
|
482
|
+
}
|
|
483
|
+
this.#sessions.set(gxToken, selectedBackend);
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
selectedBackend = backends[stableIndex(getClientIp(req), backends.length)];
|
|
487
|
+
}
|
|
408
488
|
|
|
489
|
+
const target = cleanupWebsocketUrl(`ws://${selectedBackend}${req.originalUrl}`);
|
|
409
490
|
logger.debug("proxy.web.ws.to:", target);
|
|
410
|
-
this.#
|
|
491
|
+
this.#proxyWebsocket(target, ws, req);
|
|
411
492
|
});
|
|
412
493
|
} else {
|
|
413
494
|
router[verb](uri, async (req, res, _next) => {
|
|
414
495
|
stats.proxied++;
|
|
415
|
-
backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
|
|
496
|
+
const backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
|
|
416
497
|
|
|
417
498
|
try {
|
|
418
499
|
logger.debug("proxy.web.to:", backend + req.originalUrl);
|
|
@@ -430,8 +511,12 @@ export class Gateway {
|
|
|
430
511
|
}
|
|
431
512
|
}
|
|
432
513
|
|
|
433
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Stops the gateway loop and closes the HTTP server.
|
|
516
|
+
* @returns {void}
|
|
517
|
+
*/
|
|
518
|
+
stop() {
|
|
434
519
|
this.#isActive = false;
|
|
520
|
+
clearInterval(this.#rebuildRouterInterval);
|
|
435
521
|
}
|
|
436
|
-
|
|
437
522
|
}
|
package/src/Logger.js
CHANGED
|
@@ -1,34 +1,124 @@
|
|
|
1
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, none: 4 };
|
|
2
|
+
const TAGS = { debug: "DBG", info: "INF", warn: "WRN", error: "ERR" };
|
|
3
|
+
|
|
4
|
+
const LEVEL = LEVELS[process.env.GX_LOG_LEVEL] ?? LEVELS.info;
|
|
5
|
+
const FORMAT = process.env.GX_LOG_FORMAT === "json" ? "json" : "text";
|
|
6
|
+
|
|
1
7
|
const defaultLoggerOptions = {
|
|
2
|
-
timestamp: true
|
|
8
|
+
timestamp: true,
|
|
3
9
|
};
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
function serialize(val) {
|
|
12
|
+
if (val instanceof Error) {
|
|
13
|
+
return val.stack || val.message;
|
|
14
|
+
}
|
|
15
|
+
if (typeof val === "object" && val !== null) {
|
|
16
|
+
return JSON.stringify(val);
|
|
17
|
+
}
|
|
18
|
+
return String(val);
|
|
19
|
+
}
|
|
6
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Lightweight structured logger with configurable level filtering and output format.
|
|
23
|
+
* Each log method prefixes the message with an ISO timestamp (unless disabled) and a
|
|
24
|
+
* severity tag (`INF`, `WRN`, `ERR`, `DBG`). The active level is controlled by the
|
|
25
|
+
* `GX_LOG_LEVEL` environment variable (`debug` | `info` | `warn` | `error`).
|
|
26
|
+
* The output format is controlled by `GX_LOG_FORMAT` (`text` | `json`, default `text`).
|
|
27
|
+
*/
|
|
28
|
+
export class Logger {
|
|
7
29
|
#options = defaultLoggerOptions;
|
|
30
|
+
#level = LEVEL;
|
|
31
|
+
#format = FORMAT;
|
|
8
32
|
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} [options] - Logger options.
|
|
35
|
+
* @param {boolean} [options.timestamp=true] - Whether to prepend an ISO timestamp to each line (text format only).
|
|
36
|
+
* @param {'debug'|'info'|'warn'|'error'|'none'} [options.level] - Minimum level to emit. Use `none` to suppress all output. Overrides `GX_LOG_LEVEL`.
|
|
37
|
+
* @param {'text'|'json'} [options.format] - Output format. Overrides `GX_LOG_FORMAT`.
|
|
38
|
+
*/
|
|
9
39
|
constructor(options) {
|
|
10
40
|
this.#options = { ...this.#options, ...options };
|
|
41
|
+
if (options?.level !== undefined) {
|
|
42
|
+
this.#level = LEVELS[options.level] ?? LEVEL;
|
|
43
|
+
}
|
|
44
|
+
if (options?.format !== undefined) {
|
|
45
|
+
this.#format = options.format === "json" ? "json" : "text";
|
|
46
|
+
}
|
|
11
47
|
}
|
|
12
48
|
|
|
13
|
-
#log(...args) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
49
|
+
#log(level, ...args) {
|
|
50
|
+
const stream = level === "error" ? process.stderr : process.stdout;
|
|
51
|
+
if (this.#format === "json") {
|
|
52
|
+
stream.write(
|
|
53
|
+
JSON.stringify({ time: new Date().toISOString(), level, msg: args.map(serialize).join(" ") }) + "\n",
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
const ts = this.#options.timestamp ? new Date().toISOString() : undefined;
|
|
57
|
+
stream.write(
|
|
58
|
+
[ts, TAGS[level], ...args]
|
|
59
|
+
.filter(($) => $ !== undefined)
|
|
60
|
+
.map(serialize)
|
|
61
|
+
.join(" ") + "\n",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
18
64
|
}
|
|
19
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Emits an informational log line.
|
|
68
|
+
* @param {...any} args - Values to log.
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
20
71
|
info(...args) {
|
|
21
|
-
this.#
|
|
72
|
+
if (LEVELS.info >= this.#level) {
|
|
73
|
+
this.#log("info", ...args);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Emits a warning log line.
|
|
79
|
+
* @param {...any} args - Values to log.
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
warn(...args) {
|
|
83
|
+
if (LEVELS.warn >= this.#level) {
|
|
84
|
+
this.#log("warn", ...args);
|
|
85
|
+
}
|
|
22
86
|
}
|
|
23
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Emits an error log line.
|
|
90
|
+
* @param {...any} args - Values to log.
|
|
91
|
+
* @returns {void}
|
|
92
|
+
*/
|
|
24
93
|
error(...args) {
|
|
25
|
-
this.#
|
|
94
|
+
if (LEVELS.error >= this.#level) {
|
|
95
|
+
this.#log("error", ...args);
|
|
96
|
+
}
|
|
26
97
|
}
|
|
27
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Emits a debug log line. Only shown when the active level is `debug`.
|
|
101
|
+
* @param {...any} args - Values to log.
|
|
102
|
+
* @returns {void}
|
|
103
|
+
*/
|
|
28
104
|
debug(...args) {
|
|
29
|
-
this.#
|
|
105
|
+
if (LEVELS.debug >= this.#level) {
|
|
106
|
+
this.#log("debug", ...args);
|
|
107
|
+
}
|
|
30
108
|
}
|
|
31
109
|
|
|
110
|
+
setLevel(level) {
|
|
111
|
+
this.#level = LEVELS[level] ?? LEVEL;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setFormat(format) {
|
|
115
|
+
this.#format = format === "json" ? "json" : "text";
|
|
116
|
+
}
|
|
32
117
|
}
|
|
33
118
|
|
|
34
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Default {@link Logger} instance used throughout the Geonix internals.
|
|
121
|
+
*
|
|
122
|
+
* @type {Logger}
|
|
123
|
+
*/
|
|
124
|
+
export const logger = new Logger();
|