geonix 1.23.8 → 1.30.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +1 -1
- package/README.md +348 -4
- package/exports.js +0 -2
- package/index.d.ts +292 -237
- package/package.json +11 -10
- package/src/Codec.js +20 -7
- package/src/Connection.js +94 -40
- package/src/Crypto.js +103 -0
- package/src/Gateway.js +146 -70
- package/src/Logger.js +90 -9
- package/src/Registry.js +127 -15
- package/src/Remote.js +15 -6
- package/src/Request.js +117 -80
- package/src/RequestOptions.js +11 -8
- package/src/Service.js +128 -92
- package/src/Stream.js +69 -15
- package/src/Util.js +192 -158
- package/src/WebServer.js +18 -10
- package/.claude/settings.local.json +0 -10
- package/PROJECT.md +0 -164
- package/REVIEW.md +0 -372
package/src/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,8 +47,19 @@ 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
|
}
|
|
@@ -46,16 +73,19 @@ export class Gateway {
|
|
|
46
73
|
|
|
47
74
|
#rebuildRouter = false;
|
|
48
75
|
#buildRouterRunning = false;
|
|
76
|
+
#rebuildRouterInterval = null;
|
|
49
77
|
#endpoints = [];
|
|
50
78
|
|
|
51
79
|
#registry = {};
|
|
80
|
+
#server;
|
|
81
|
+
#sessions = new Map(); // _gx token → backend address
|
|
52
82
|
|
|
53
83
|
constructor(opts) {
|
|
54
84
|
expressWs(this.#api);
|
|
55
85
|
|
|
56
86
|
this.#opts = { ...this.#opts, ...opts };
|
|
57
87
|
|
|
58
|
-
this.#start();
|
|
88
|
+
this.#start().catch(e => logger.error("gx.gateway.start:", e));
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
async #start(port = 8080) {
|
|
@@ -63,36 +93,59 @@ export class Gateway {
|
|
|
63
93
|
|
|
64
94
|
await connection.waitUntilReady();
|
|
65
95
|
|
|
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}`);
|
|
96
|
+
this.#port = process.env.GX_PORT || process.env.PORT || port;
|
|
70
97
|
|
|
71
98
|
// logging
|
|
72
99
|
this.#api.use(requestLogger);
|
|
73
100
|
|
|
74
101
|
// cors
|
|
75
102
|
this.#api.use((req, res, next) => {
|
|
103
|
+
const cors = this.#opts.cors;
|
|
76
104
|
const origin = req.headers["origin"];
|
|
77
105
|
const allMethods = "GET,PUT,POST,DELETE,OPTIONS,HEAD";
|
|
78
|
-
const allHeaders = "*";
|
|
79
106
|
const requestMethod = req.headers["access-control-request-method"];
|
|
80
107
|
const requestHeaders = req.headers["access-control-request-headers"];
|
|
81
108
|
|
|
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
109
|
res.set("Allow", requestMethod || allMethods);
|
|
86
|
-
|
|
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
|
+
}
|
|
87
135
|
|
|
88
136
|
next();
|
|
89
137
|
});
|
|
90
138
|
|
|
91
|
-
// debug router (only
|
|
92
|
-
if (process.env.
|
|
93
|
-
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());
|
|
94
142
|
}
|
|
95
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
|
+
|
|
96
149
|
this.#api.use((req, res, next) => {
|
|
97
150
|
if (this.#opts.beforeRequest) {
|
|
98
151
|
this.#opts.beforeRequest(req, res);
|
|
@@ -130,7 +183,10 @@ export class Gateway {
|
|
|
130
183
|
});
|
|
131
184
|
});
|
|
132
185
|
|
|
133
|
-
|
|
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(() => {
|
|
134
190
|
if (this.#rebuildRouter) {
|
|
135
191
|
this.#buildRouter();
|
|
136
192
|
}
|
|
@@ -141,7 +197,7 @@ export class Gateway {
|
|
|
141
197
|
// send keeplive to check if connection is still alive
|
|
142
198
|
await connection.publish("gx.gateway.keepalive", Date.now());
|
|
143
199
|
|
|
144
|
-
await this.#
|
|
200
|
+
await this.#handleAddedServices();
|
|
145
201
|
await this.#handleRemovedServices();
|
|
146
202
|
await sleep(1000);
|
|
147
203
|
|
|
@@ -153,42 +209,31 @@ export class Gateway {
|
|
|
153
209
|
}
|
|
154
210
|
}
|
|
155
211
|
|
|
156
|
-
|
|
212
|
+
this.#server?.close();
|
|
157
213
|
|
|
158
214
|
// terminate process
|
|
159
|
-
logger.
|
|
215
|
+
logger.info("geonix.gateway: stopped");
|
|
160
216
|
}
|
|
161
217
|
|
|
162
|
-
async #
|
|
218
|
+
async #handleAddedServices() {
|
|
163
219
|
let entries = Object.values(registry.getEntries());
|
|
164
220
|
|
|
165
221
|
const processEntry = async (entry) => {
|
|
166
|
-
|
|
222
|
+
const existing = this.#registry[entry.i];
|
|
167
223
|
|
|
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
|
-
}
|
|
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;
|
|
187
229
|
}
|
|
230
|
+
return false;
|
|
188
231
|
}
|
|
189
232
|
|
|
233
|
+
logger.info(`gateway.onServiceAdded: ${entry.n}@${entry.v} (#${entry.i})`);
|
|
234
|
+
|
|
190
235
|
let proxy;
|
|
191
|
-
if (!
|
|
236
|
+
if (!entry.a?.length) {
|
|
192
237
|
// create proxy over nats
|
|
193
238
|
proxy = await createTCPServer(async (client) => {
|
|
194
239
|
const streamId = picoid();
|
|
@@ -200,11 +245,10 @@ export class Gateway {
|
|
|
200
245
|
logger.error("nats.proxy.error", e);
|
|
201
246
|
client.destroy();
|
|
202
247
|
}
|
|
203
|
-
}
|
|
204
|
-
backend = `127.0.0.1:${proxy.port}`;
|
|
248
|
+
});
|
|
205
249
|
}
|
|
206
250
|
|
|
207
|
-
this.#registry[entry.i] = { entry, proxy,
|
|
251
|
+
this.#registry[entry.i] = { entry, proxy, knownAddressCount: entry.a?.length ?? 0 };
|
|
208
252
|
|
|
209
253
|
return true;
|
|
210
254
|
};
|
|
@@ -270,7 +314,6 @@ export class Gateway {
|
|
|
270
314
|
platform: process.platform,
|
|
271
315
|
arch: process.arch
|
|
272
316
|
},
|
|
273
|
-
env: process.env,
|
|
274
317
|
mem: process.memoryUsage(),
|
|
275
318
|
rss: process.memoryUsage.rss(),
|
|
276
319
|
cpu: process.cpuUsage()
|
|
@@ -293,15 +336,15 @@ export class Gateway {
|
|
|
293
336
|
connection.unsubscribe(ingress);
|
|
294
337
|
connection.publish(`gx2.stream.${streamId}.c`, Buffer.from("end"));
|
|
295
338
|
});
|
|
296
|
-
client.on("error", (
|
|
297
|
-
|
|
339
|
+
client.on("error", (e) => {
|
|
340
|
+
logger.debug("nats.proxy.client.error:", e);
|
|
298
341
|
});
|
|
299
342
|
|
|
300
343
|
const dataLoop = async () => {
|
|
301
344
|
for await (const event of ingress) { client.write(event.data); }
|
|
302
345
|
};
|
|
303
346
|
|
|
304
|
-
dataLoop();
|
|
347
|
+
dataLoop().catch(e => logger.error("nats.proxy.dataLoop:", e));
|
|
305
348
|
}
|
|
306
349
|
}
|
|
307
350
|
|
|
@@ -312,12 +355,13 @@ export class Gateway {
|
|
|
312
355
|
* @param {Readable} inbound
|
|
313
356
|
* @param {Request} req
|
|
314
357
|
*/
|
|
315
|
-
#
|
|
358
|
+
#proxyWebsocket(target, inbound, req) {
|
|
316
359
|
try {
|
|
317
|
-
const backend = new WebSocket(target, {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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();
|
|
321
365
|
});
|
|
322
366
|
|
|
323
367
|
backend.on("open", () => {
|
|
@@ -328,7 +372,8 @@ export class Gateway {
|
|
|
328
372
|
inbound.on("close", () => backend.close());
|
|
329
373
|
});
|
|
330
374
|
} catch (e) {
|
|
331
|
-
logger.error(e);
|
|
375
|
+
logger.error("proxy.ws.backend.error:", e);
|
|
376
|
+
inbound.close();
|
|
332
377
|
}
|
|
333
378
|
}
|
|
334
379
|
|
|
@@ -348,14 +393,23 @@ export class Gateway {
|
|
|
348
393
|
|
|
349
394
|
const endpoints = [];
|
|
350
395
|
|
|
351
|
-
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
|
+
|
|
352
403
|
// generate global endpoint list
|
|
353
404
|
for (let e of entry.m) {
|
|
354
|
-
|
|
355
|
-
|
|
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);
|
|
356
410
|
let options = {
|
|
357
|
-
|
|
358
|
-
|
|
411
|
+
...parsed,
|
|
412
|
+
order: Number.isNaN(parsedOrder) ? 100 : parsedOrder
|
|
359
413
|
};
|
|
360
414
|
|
|
361
415
|
try {
|
|
@@ -364,7 +418,7 @@ export class Gateway {
|
|
|
364
418
|
version: semver.coerce(entry.v).version,
|
|
365
419
|
options,
|
|
366
420
|
endpoint,
|
|
367
|
-
backend: [
|
|
421
|
+
backend: [backendAddr]
|
|
368
422
|
});
|
|
369
423
|
} catch (e) {
|
|
370
424
|
logger.error("gateway.buildRouter.error:", entry);
|
|
@@ -391,28 +445,45 @@ export class Gateway {
|
|
|
391
445
|
|
|
392
446
|
// sort endpoints by order, if there is one
|
|
393
447
|
endpoints.sort((a, b) => semver.rcompare(a.version, b.version));
|
|
394
|
-
endpoints.sort((a, b) =>
|
|
448
|
+
endpoints.sort((a, b) => a.options.order - b.options.order);
|
|
395
449
|
|
|
396
450
|
this.#endpoints = endpoints;
|
|
397
451
|
|
|
398
452
|
// build the router
|
|
399
453
|
for (let endpoint of endpoints) {
|
|
400
454
|
let { verb, url: uri } = endpoint.endpoint;
|
|
401
|
-
let backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
|
|
402
|
-
|
|
403
455
|
verb = verb.toLowerCase();
|
|
404
456
|
|
|
405
457
|
if (verb === "ws") {
|
|
406
458
|
router.ws(uri, (ws, req) => {
|
|
407
|
-
|
|
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
|
+
}
|
|
408
478
|
|
|
479
|
+
const target = cleanupWebsocketUrl(`ws://${selectedBackend}${req.originalUrl}`);
|
|
409
480
|
logger.debug("proxy.web.ws.to:", target);
|
|
410
|
-
this.#
|
|
481
|
+
this.#proxyWebsocket(target, ws, req);
|
|
411
482
|
});
|
|
412
483
|
} else {
|
|
413
484
|
router[verb](uri, async (req, res, _next) => {
|
|
414
485
|
stats.proxied++;
|
|
415
|
-
backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
|
|
486
|
+
const backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
|
|
416
487
|
|
|
417
488
|
try {
|
|
418
489
|
logger.debug("proxy.web.to:", backend + req.originalUrl);
|
|
@@ -430,8 +501,13 @@ export class Gateway {
|
|
|
430
501
|
}
|
|
431
502
|
}
|
|
432
503
|
|
|
433
|
-
|
|
504
|
+
/**
|
|
505
|
+
* Stops the gateway loop and closes the HTTP server.
|
|
506
|
+
* @returns {void}
|
|
507
|
+
*/
|
|
508
|
+
stop() {
|
|
434
509
|
this.#isActive = false;
|
|
510
|
+
clearInterval(this.#rebuildRouterInterval);
|
|
435
511
|
}
|
|
436
512
|
|
|
437
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();
|