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