@singhak/nodeui-core 0.1.0 → 0.3.1
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/README.md +59 -30
- package/dist/index.cjs +652 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +197 -10
- package/dist/index.d.ts +197 -10
- package/dist/index.mjs +632 -49
- package/dist/index.mjs.map +1 -1
- package/package.json +63 -63
- package/static/assets/index-Cu0xtlkD.js +11 -0
- package/static/assets/index-DlUszrsn.css +1 -0
- package/static/index.html +14 -13
- package/static/assets/index-DbLC8b3x.css +0 -1
- package/static/assets/index-DowJtJYZ.js +0 -10
package/dist/index.mjs
CHANGED
|
@@ -50,7 +50,15 @@ var RingBuffer = class {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
// src/safety.ts
|
|
53
|
-
var SECRET_KEY_PATTERN = /token|key|secret|
|
|
53
|
+
var SECRET_KEY_PATTERN = /token|key|secret|passw(?:or)?d|credential|auth|cookie|dsn|private|signature|session|bearer|connection.?string|database.?url|conn.?str/i;
|
|
54
|
+
var TEXT_PAIR_PATTERN = /\b([\w.-]*(?:token|secret|passw(?:or)?d|api[_-]?key|credential|authorization|cookie|dsn)[\w.-]*["']?)(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s,;&"'}]+)/gi;
|
|
55
|
+
var URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+(@)/gi;
|
|
56
|
+
var BEARER_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
|
57
|
+
var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g;
|
|
58
|
+
function maskSecretText(text) {
|
|
59
|
+
if (text.length < 6) return text;
|
|
60
|
+
return text.replace(URL_CREDENTIALS_PATTERN, `$1${SECRET_MASKED}$2`).replace(BEARER_PATTERN, `$1 ${SECRET_MASKED}`).replace(JWT_PATTERN, SECRET_MASKED).replace(TEXT_PAIR_PATTERN, `$1$2${SECRET_MASKED}`);
|
|
61
|
+
}
|
|
54
62
|
function resolveActivation(env) {
|
|
55
63
|
if (env.NODEUI_ENABLED === "true") {
|
|
56
64
|
return { active: true, reason: "activated by NODEUI_ENABLED=true" };
|
|
@@ -73,7 +81,55 @@ function isLoopbackAddress(address) {
|
|
|
73
81
|
if (!address) return false;
|
|
74
82
|
return address.startsWith("127.") || address === "::1" || address.startsWith("::ffff:127.") || address === "localhost";
|
|
75
83
|
}
|
|
84
|
+
function normalizeIp(address) {
|
|
85
|
+
return address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
86
|
+
}
|
|
87
|
+
function ipv4ToInt(ip) {
|
|
88
|
+
const parts = ip.split(".");
|
|
89
|
+
if (parts.length !== 4) return null;
|
|
90
|
+
let out = 0;
|
|
91
|
+
for (const part of parts) {
|
|
92
|
+
const n = Number(part);
|
|
93
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) return null;
|
|
94
|
+
out = out * 256 + n;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function matchesAddress(address, allowed) {
|
|
99
|
+
if (!address) return false;
|
|
100
|
+
const ip = normalizeIp(address);
|
|
101
|
+
for (const entry of allowed) {
|
|
102
|
+
if (!entry.includes("/")) {
|
|
103
|
+
if (normalizeIp(entry) === ip) return true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const [base, bitsRaw] = entry.split("/");
|
|
107
|
+
const bits = Number(bitsRaw);
|
|
108
|
+
const a = ipv4ToInt(ip);
|
|
109
|
+
const b = ipv4ToInt(base ?? "");
|
|
110
|
+
if (a === null || b === null || !Number.isInteger(bits) || bits < 0 || bits > 32) continue;
|
|
111
|
+
const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
|
|
112
|
+
if ((a & mask) >>> 0 === (b & mask) >>> 0) return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
function hostnameFromHostHeader(value) {
|
|
117
|
+
if (!value) return null;
|
|
118
|
+
const trimmed = value.trim().toLowerCase();
|
|
119
|
+
if (trimmed.startsWith("[")) {
|
|
120
|
+
const end = trimmed.indexOf("]");
|
|
121
|
+
return end === -1 ? null : trimmed.slice(1, end);
|
|
122
|
+
}
|
|
123
|
+
return trimmed.split(":")[0] || null;
|
|
124
|
+
}
|
|
125
|
+
function isLoopbackHostname(hostname) {
|
|
126
|
+
if (!hostname) return false;
|
|
127
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname);
|
|
128
|
+
}
|
|
76
129
|
function maskSecrets(value, pattern = SECRET_KEY_PATTERN) {
|
|
130
|
+
if (typeof value === "string") {
|
|
131
|
+
return maskSecretText(value);
|
|
132
|
+
}
|
|
77
133
|
if (value !== null && typeof value === "object") {
|
|
78
134
|
if (Array.isArray(value)) {
|
|
79
135
|
return value.map((v) => maskSecrets(v, pattern));
|
|
@@ -97,16 +153,132 @@ function maskSecrets(value, pattern = SECRET_KEY_PATTERN) {
|
|
|
97
153
|
return value;
|
|
98
154
|
}
|
|
99
155
|
|
|
156
|
+
// src/guard.ts
|
|
157
|
+
import { createHash, timingSafeEqual } from "crypto";
|
|
158
|
+
var AUTH_COOKIE = "nodeui_token";
|
|
159
|
+
var FORWARD_HEADERS = ["x-forwarded-for", "x-forwarded-host", "forwarded", "x-real-ip"];
|
|
160
|
+
function digest(value) {
|
|
161
|
+
return createHash("sha256").update(value).digest();
|
|
162
|
+
}
|
|
163
|
+
function tokenEquals(a, b) {
|
|
164
|
+
return timingSafeEqual(digest(a), digest(b));
|
|
165
|
+
}
|
|
166
|
+
function readCookie(header, name) {
|
|
167
|
+
if (!header) return void 0;
|
|
168
|
+
for (const part of header.split(";")) {
|
|
169
|
+
const [k, ...rest] = part.trim().split("=");
|
|
170
|
+
if (k === name) {
|
|
171
|
+
try {
|
|
172
|
+
return decodeURIComponent(rest.join("="));
|
|
173
|
+
} catch {
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return void 0;
|
|
179
|
+
}
|
|
180
|
+
function firstHeader(value) {
|
|
181
|
+
return Array.isArray(value) ? value[0] : value;
|
|
182
|
+
}
|
|
183
|
+
function deny(status, code, message) {
|
|
184
|
+
return { ok: false, status, code, message };
|
|
185
|
+
}
|
|
186
|
+
function createGuard(options) {
|
|
187
|
+
const strict = isLoopbackAddress(options.host);
|
|
188
|
+
const allowedHosts = options.allowedHosts.map((h) => h.toLowerCase());
|
|
189
|
+
const allowedOrigins = options.allowedOrigins.map((o) => o.toLowerCase().replace(/\/+$/, ""));
|
|
190
|
+
return (req) => {
|
|
191
|
+
const remote = req.socket.remoteAddress;
|
|
192
|
+
const remoteLoopback = isLoopbackAddress(remote);
|
|
193
|
+
const remoteAllowed = remoteLoopback || matchesAddress(remote, options.allowedRemoteAddresses);
|
|
194
|
+
if (strict && !remoteAllowed) {
|
|
195
|
+
return deny(
|
|
196
|
+
403,
|
|
197
|
+
"forbidden",
|
|
198
|
+
"nodeui is loopback-only; requests from other hosts are rejected"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (!strict && options.allowedRemoteAddresses.length > 0 && !remoteAllowed) {
|
|
202
|
+
return deny(403, "forbidden", "remote address is not in the nodeui allow-list");
|
|
203
|
+
}
|
|
204
|
+
if (strict && !options.trustProxy && !matchesAddress(remote, options.allowedRemoteAddresses)) {
|
|
205
|
+
if (FORWARD_HEADERS.some((h) => req.headers[h] !== void 0)) {
|
|
206
|
+
return deny(
|
|
207
|
+
403,
|
|
208
|
+
"forbidden",
|
|
209
|
+
"proxied requests are rejected (set trustProxy to allow forwarded traffic)"
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const hostHeader = firstHeader(req.headers.host);
|
|
214
|
+
const hostname = hostnameFromHostHeader(hostHeader);
|
|
215
|
+
if (hostname !== null) {
|
|
216
|
+
const hostOk = isLoopbackHostname(hostname) || allowedHosts.includes(hostname) || !strict && allowedHosts.length === 0;
|
|
217
|
+
if (!hostOk) {
|
|
218
|
+
return deny(
|
|
219
|
+
403,
|
|
220
|
+
"forbidden",
|
|
221
|
+
`Host "${hostname}" is not allowed (add it to allowedHosts to use nodeui via this name)`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const origin = firstHeader(req.headers.origin);
|
|
226
|
+
if (origin !== void 0) {
|
|
227
|
+
let originOk = false;
|
|
228
|
+
const normalized = origin.toLowerCase().replace(/\/+$/, "");
|
|
229
|
+
if (allowedOrigins.includes(normalized)) {
|
|
230
|
+
originOk = true;
|
|
231
|
+
} else {
|
|
232
|
+
try {
|
|
233
|
+
originOk = hostHeader !== void 0 && new URL(origin).host === hostHeader.toLowerCase();
|
|
234
|
+
} catch {
|
|
235
|
+
originOk = false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!originOk) {
|
|
239
|
+
return deny(403, "forbidden", "cross-origin requests to nodeui are rejected");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (options.authToken) {
|
|
243
|
+
const authHeader = firstHeader(req.headers.authorization);
|
|
244
|
+
const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : void 0;
|
|
245
|
+
const queryToken = new URL(req.url ?? "/", "http://localhost").searchParams.get("token");
|
|
246
|
+
const cookieToken = readCookie(firstHeader(req.headers.cookie), AUTH_COOKIE);
|
|
247
|
+
const candidates = [
|
|
248
|
+
bearer,
|
|
249
|
+
firstHeader(req.headers["x-nodeui-token"]),
|
|
250
|
+
cookieToken,
|
|
251
|
+
queryToken ?? void 0
|
|
252
|
+
];
|
|
253
|
+
const matched = candidates.find((c) => c !== void 0 && tokenEquals(c, options.authToken));
|
|
254
|
+
if (matched === void 0) {
|
|
255
|
+
return deny(401, "unauthorized", "a valid nodeui token is required");
|
|
256
|
+
}
|
|
257
|
+
if (queryToken !== null && matched === queryToken) {
|
|
258
|
+
return { ok: true, setToken: queryToken };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { ok: true };
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
100
265
|
// src/confirmations.ts
|
|
101
266
|
import { randomBytes } from "crypto";
|
|
102
267
|
var ConfirmationStore = class {
|
|
103
|
-
constructor(ttlMs) {
|
|
268
|
+
constructor(ttlMs, maxOutstanding = 100) {
|
|
104
269
|
this.ttlMs = ttlMs;
|
|
270
|
+
this.maxOutstanding = maxOutstanding;
|
|
105
271
|
}
|
|
106
272
|
ttlMs;
|
|
273
|
+
maxOutstanding;
|
|
107
274
|
nonces = /* @__PURE__ */ new Map();
|
|
108
275
|
issue() {
|
|
109
276
|
this.prune();
|
|
277
|
+
while (this.nonces.size >= this.maxOutstanding) {
|
|
278
|
+
const oldest = this.nonces.keys().next().value;
|
|
279
|
+
if (oldest === void 0) break;
|
|
280
|
+
this.nonces.delete(oldest);
|
|
281
|
+
}
|
|
110
282
|
const nonce = randomBytes(16).toString("hex");
|
|
111
283
|
const expiresAtMs = Date.now() + this.ttlMs;
|
|
112
284
|
this.nonces.set(nonce, expiresAtMs);
|
|
@@ -419,15 +591,65 @@ var EventLoopLagProvider = class {
|
|
|
419
591
|
// src/providers/health.ts
|
|
420
592
|
var LAG_OK_MS = 50;
|
|
421
593
|
var LAG_DEGRADED_MS = 200;
|
|
594
|
+
var CHECK_TIMEOUT_MS = 3e3;
|
|
595
|
+
var CHECK_CACHE_MS = 5e3;
|
|
596
|
+
function withTimeout(promise, ms) {
|
|
597
|
+
return new Promise((resolve3, reject) => {
|
|
598
|
+
const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
599
|
+
promise.then(
|
|
600
|
+
(value) => {
|
|
601
|
+
clearTimeout(timer);
|
|
602
|
+
resolve3(value);
|
|
603
|
+
},
|
|
604
|
+
(err) => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
607
|
+
}
|
|
608
|
+
);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
422
611
|
var HealthProvider = class {
|
|
612
|
+
constructor(checks = {}) {
|
|
613
|
+
this.checks = checks;
|
|
614
|
+
}
|
|
615
|
+
checks;
|
|
423
616
|
id = "health";
|
|
424
|
-
|
|
617
|
+
cache = null;
|
|
618
|
+
async runChecks() {
|
|
619
|
+
const names = Object.keys(this.checks);
|
|
620
|
+
if (names.length === 0) return [];
|
|
621
|
+
if (this.cache && Date.now() - this.cache.at < CHECK_CACHE_MS) return this.cache.results;
|
|
622
|
+
const results = await Promise.all(
|
|
623
|
+
names.map(async (name) => {
|
|
624
|
+
const started = Date.now();
|
|
625
|
+
try {
|
|
626
|
+
const outcome = await withTimeout(
|
|
627
|
+
Promise.resolve().then(() => this.checks[name]()),
|
|
628
|
+
CHECK_TIMEOUT_MS
|
|
629
|
+
);
|
|
630
|
+
if (outcome === false) throw new Error("check returned false");
|
|
631
|
+
return { name, status: "up", durationMs: Date.now() - started };
|
|
632
|
+
} catch (err) {
|
|
633
|
+
return {
|
|
634
|
+
name,
|
|
635
|
+
status: "down",
|
|
636
|
+
durationMs: Date.now() - started,
|
|
637
|
+
error: err instanceof Error ? err.message : String(err)
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
);
|
|
642
|
+
this.cache = { at: Date.now(), results };
|
|
643
|
+
return results;
|
|
644
|
+
}
|
|
645
|
+
async get(ctx) {
|
|
425
646
|
const loop = ctx.store["event-loop"];
|
|
426
647
|
const memory = ctx.store.memory;
|
|
427
648
|
const lagMs = typeof loop?.currentMs === "number" ? loop.currentMs : null;
|
|
428
649
|
const totalMem = typeof memory?.totalMem === "number" ? memory.totalMem : 0;
|
|
429
650
|
const rss = typeof memory?.rss === "number" ? memory.rss : 0;
|
|
430
651
|
const memoryUsedPercent = totalMem > 0 ? rss / totalMem * 100 : null;
|
|
652
|
+
const checks = await this.runChecks();
|
|
431
653
|
let status;
|
|
432
654
|
let statusReason;
|
|
433
655
|
if (lagMs === null) {
|
|
@@ -443,6 +665,11 @@ var HealthProvider = class {
|
|
|
443
665
|
status = "critical";
|
|
444
666
|
statusReason = `event-loop lag ${lagMs.toFixed(1)}ms above ${LAG_DEGRADED_MS}ms`;
|
|
445
667
|
}
|
|
668
|
+
const down = checks.filter((c) => c.status === "down");
|
|
669
|
+
if (down.length > 0) {
|
|
670
|
+
status = "critical";
|
|
671
|
+
statusReason = `dependency down: ${down.map((c) => c.name).join(", ")}`;
|
|
672
|
+
}
|
|
446
673
|
return {
|
|
447
674
|
ok: true,
|
|
448
675
|
data: {
|
|
@@ -453,19 +680,202 @@ var HealthProvider = class {
|
|
|
453
680
|
nodeVersion: process.version,
|
|
454
681
|
platform: process.platform,
|
|
455
682
|
eventLoopLagMs: lagMs,
|
|
456
|
-
memoryUsedPercent
|
|
683
|
+
memoryUsedPercent,
|
|
684
|
+
checks
|
|
457
685
|
}
|
|
458
686
|
};
|
|
459
687
|
}
|
|
460
688
|
};
|
|
461
689
|
|
|
690
|
+
// src/providers/outgoing.ts
|
|
691
|
+
import diagnosticsChannel from "diagnostics_channel";
|
|
692
|
+
import http from "http";
|
|
693
|
+
import https from "https";
|
|
694
|
+
import { syncBuiltinESMExports } from "module";
|
|
695
|
+
var RESPONSE_PAGE_SIZE = 100;
|
|
696
|
+
var recorders = /* @__PURE__ */ new Set();
|
|
697
|
+
var originals = /* @__PURE__ */ new Map();
|
|
698
|
+
var undiciUnsubscribe = null;
|
|
699
|
+
function describeTarget(defaultProtocol, args) {
|
|
700
|
+
try {
|
|
701
|
+
let method = "GET";
|
|
702
|
+
let host = "unknown";
|
|
703
|
+
let path2 = "/";
|
|
704
|
+
const [first, second] = args;
|
|
705
|
+
const opts = typeof first === "string" || first instanceof URL ? second : first;
|
|
706
|
+
if (typeof first === "string" || first instanceof URL) {
|
|
707
|
+
const parsed = new URL(first.toString());
|
|
708
|
+
host = parsed.host;
|
|
709
|
+
path2 = parsed.pathname;
|
|
710
|
+
}
|
|
711
|
+
if (opts && typeof opts === "object") {
|
|
712
|
+
if (typeof opts.method === "string") method = opts.method.toUpperCase();
|
|
713
|
+
if (typeof first !== "string" && !(first instanceof URL)) {
|
|
714
|
+
const name = opts.hostname ?? opts.host ?? "localhost";
|
|
715
|
+
host = opts.port ? `${name}:${String(opts.port)}` : name;
|
|
716
|
+
path2 = typeof opts.path === "string" ? opts.path.split("?")[0] ?? "/" : "/";
|
|
717
|
+
const protocol = typeof opts.protocol === "string" ? opts.protocol : defaultProtocol;
|
|
718
|
+
return { method, url: `${protocol}//${host}${path2}` };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return { method, url: `${defaultProtocol}//${host}${path2}` };
|
|
722
|
+
} catch {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function emit(entry) {
|
|
727
|
+
for (const record of recorders) {
|
|
728
|
+
try {
|
|
729
|
+
record(entry);
|
|
730
|
+
} catch {
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function wrapModule(mod, protocol) {
|
|
735
|
+
for (const name of ["request", "get"]) {
|
|
736
|
+
const key = `${protocol}${name}`;
|
|
737
|
+
const original = mod[name];
|
|
738
|
+
originals.set(key, original);
|
|
739
|
+
const wrapped = function(...args) {
|
|
740
|
+
const req = original.apply(this, args);
|
|
741
|
+
const target = recorders.size > 0 ? describeTarget(protocol, args) : null;
|
|
742
|
+
if (target && req && typeof req.once === "function") {
|
|
743
|
+
const started = process.hrtime.bigint();
|
|
744
|
+
const timestampMs = Date.now();
|
|
745
|
+
let done = false;
|
|
746
|
+
const finish = (status, error) => {
|
|
747
|
+
if (done) return;
|
|
748
|
+
done = true;
|
|
749
|
+
emit({
|
|
750
|
+
...target,
|
|
751
|
+
status,
|
|
752
|
+
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
753
|
+
timestampMs,
|
|
754
|
+
...error ? { error } : {}
|
|
755
|
+
});
|
|
756
|
+
};
|
|
757
|
+
req.once("response", (res) => {
|
|
758
|
+
const status = res.statusCode ?? null;
|
|
759
|
+
res.once("end", () => finish(status));
|
|
760
|
+
res.once("close", () => finish(status));
|
|
761
|
+
});
|
|
762
|
+
req.once("error", (err) => finish(null, err.message));
|
|
763
|
+
}
|
|
764
|
+
return req;
|
|
765
|
+
};
|
|
766
|
+
mod[name] = wrapped;
|
|
767
|
+
}
|
|
768
|
+
syncBuiltinESMExports();
|
|
769
|
+
}
|
|
770
|
+
function unwrapModule(mod, protocol) {
|
|
771
|
+
for (const name of ["request", "get"]) {
|
|
772
|
+
const original = originals.get(`${protocol}${name}`);
|
|
773
|
+
if (original) mod[name] = original;
|
|
774
|
+
originals.delete(`${protocol}${name}`);
|
|
775
|
+
}
|
|
776
|
+
syncBuiltinESMExports();
|
|
777
|
+
}
|
|
778
|
+
function subscribeUndici() {
|
|
779
|
+
const started = /* @__PURE__ */ new WeakMap();
|
|
780
|
+
const statuses = /* @__PURE__ */ new WeakMap();
|
|
781
|
+
const target = (req) => ({
|
|
782
|
+
method: (req.method ?? "GET").toUpperCase(),
|
|
783
|
+
url: `${String(req.origin ?? "")}${(req.path ?? "/").split("?")[0]}`
|
|
784
|
+
});
|
|
785
|
+
const onCreate = (message) => {
|
|
786
|
+
const request = message.request;
|
|
787
|
+
if (request) started.set(request, { at: process.hrtime.bigint(), ts: Date.now() });
|
|
788
|
+
};
|
|
789
|
+
const onHeaders = (message) => {
|
|
790
|
+
const { request, response } = message;
|
|
791
|
+
if (request && typeof response?.statusCode === "number") {
|
|
792
|
+
statuses.set(request, response.statusCode);
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
const finish = (request, error) => {
|
|
796
|
+
if (!request) return;
|
|
797
|
+
const begin = started.get(request);
|
|
798
|
+
if (!begin) return;
|
|
799
|
+
started.delete(request);
|
|
800
|
+
emit({
|
|
801
|
+
...target(request),
|
|
802
|
+
status: statuses.get(request) ?? null,
|
|
803
|
+
durationMs: Number(process.hrtime.bigint() - begin.at) / 1e6,
|
|
804
|
+
timestampMs: begin.ts,
|
|
805
|
+
...error ? { error } : {}
|
|
806
|
+
});
|
|
807
|
+
};
|
|
808
|
+
const onTrailers = (message) => finish(message.request);
|
|
809
|
+
const onError = (message) => {
|
|
810
|
+
const { request, error } = message;
|
|
811
|
+
finish(request, error?.message ?? "request failed");
|
|
812
|
+
};
|
|
813
|
+
diagnosticsChannel.subscribe("undici:request:create", onCreate);
|
|
814
|
+
diagnosticsChannel.subscribe("undici:request:headers", onHeaders);
|
|
815
|
+
diagnosticsChannel.subscribe("undici:request:trailers", onTrailers);
|
|
816
|
+
diagnosticsChannel.subscribe("undici:request:error", onError);
|
|
817
|
+
return () => {
|
|
818
|
+
diagnosticsChannel.unsubscribe("undici:request:create", onCreate);
|
|
819
|
+
diagnosticsChannel.unsubscribe("undici:request:headers", onHeaders);
|
|
820
|
+
diagnosticsChannel.unsubscribe("undici:request:trailers", onTrailers);
|
|
821
|
+
diagnosticsChannel.unsubscribe("undici:request:error", onError);
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
function interceptOutgoing(record) {
|
|
825
|
+
const first = recorders.size === 0;
|
|
826
|
+
recorders.add(record);
|
|
827
|
+
if (first) {
|
|
828
|
+
wrapModule(http, "http:");
|
|
829
|
+
wrapModule(https, "https:");
|
|
830
|
+
try {
|
|
831
|
+
undiciUnsubscribe = subscribeUndici();
|
|
832
|
+
} catch {
|
|
833
|
+
undiciUnsubscribe = null;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return () => {
|
|
837
|
+
if (!recorders.delete(record) || recorders.size > 0) return;
|
|
838
|
+
unwrapModule(http, "http:");
|
|
839
|
+
unwrapModule(https, "https:");
|
|
840
|
+
undiciUnsubscribe?.();
|
|
841
|
+
undiciUnsubscribe = null;
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
var OutgoingProvider = class {
|
|
845
|
+
id = "outgoing";
|
|
846
|
+
buffer;
|
|
847
|
+
nextId = 1;
|
|
848
|
+
failed = 0;
|
|
849
|
+
release = null;
|
|
850
|
+
constructor(size) {
|
|
851
|
+
this.buffer = new RingBuffer(size);
|
|
852
|
+
}
|
|
853
|
+
start() {
|
|
854
|
+
if (this.release) return;
|
|
855
|
+
this.release = interceptOutgoing((entry) => this.record(entry));
|
|
856
|
+
}
|
|
857
|
+
stop() {
|
|
858
|
+
this.release?.();
|
|
859
|
+
this.release = null;
|
|
860
|
+
}
|
|
861
|
+
record(entry) {
|
|
862
|
+
this.buffer.push({ id: this.nextId, ...entry });
|
|
863
|
+
this.nextId += 1;
|
|
864
|
+
if (entry.error || entry.status !== null && entry.status >= 500) this.failed += 1;
|
|
865
|
+
}
|
|
866
|
+
get() {
|
|
867
|
+
const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE));
|
|
868
|
+
return { ok: true, data: { total: this.nextId - 1, failed: this.failed, entries } };
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
|
|
462
872
|
// src/providers/heap-snapshot.ts
|
|
463
|
-
import { mkdir, stat } from "fs/promises";
|
|
873
|
+
import { chmod, mkdir, stat } from "fs/promises";
|
|
464
874
|
import { tmpdir } from "os";
|
|
465
875
|
import { join as join2 } from "path";
|
|
466
876
|
import { writeHeapSnapshot } from "v8";
|
|
467
877
|
function defaultSnapshotDir() {
|
|
468
|
-
return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? tmpdir();
|
|
878
|
+
return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? join2(tmpdir(), "nodeui-heap");
|
|
469
879
|
}
|
|
470
880
|
var HeapSnapshotProvider = class {
|
|
471
881
|
id = "heap-snapshot";
|
|
@@ -479,8 +889,9 @@ var HeapSnapshotProvider = class {
|
|
|
479
889
|
const fileName = `nodeui-heap-${process.pid}-${stamp}.heapsnapshot`;
|
|
480
890
|
const filePath = join2(dir, fileName);
|
|
481
891
|
try {
|
|
482
|
-
await mkdir(dir, { recursive: true });
|
|
892
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
483
893
|
writeHeapSnapshot(filePath);
|
|
894
|
+
await chmod(filePath, 384).catch(() => void 0);
|
|
484
895
|
const fileStat = await stat(filePath);
|
|
485
896
|
const data = {
|
|
486
897
|
fileName,
|
|
@@ -503,7 +914,55 @@ var HeapSnapshotProvider = class {
|
|
|
503
914
|
};
|
|
504
915
|
|
|
505
916
|
// src/providers/requests.ts
|
|
506
|
-
var
|
|
917
|
+
var RESPONSE_PAGE_SIZE2 = 100;
|
|
918
|
+
var MAX_ROUTE_STATS = 8;
|
|
919
|
+
function percentile(sorted, p) {
|
|
920
|
+
if (sorted.length === 0) return 0;
|
|
921
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
|
|
922
|
+
return sorted[index] ?? 0;
|
|
923
|
+
}
|
|
924
|
+
function routeKey(path2) {
|
|
925
|
+
return path2.split("/").map(
|
|
926
|
+
(seg) => /^\d+$/.test(seg) || /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(seg) || /^[0-9a-f]{24}$/i.test(seg) ? ":id" : seg
|
|
927
|
+
).join("/");
|
|
928
|
+
}
|
|
929
|
+
function summarizeRequests(entries) {
|
|
930
|
+
const durations = entries.map((e) => e.durationMs).sort((a, b) => a - b);
|
|
931
|
+
const byStatus = { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0 };
|
|
932
|
+
const groups = /* @__PURE__ */ new Map();
|
|
933
|
+
for (const e of entries) {
|
|
934
|
+
const bucket = `${Math.floor(e.status / 100)}xx`;
|
|
935
|
+
if (bucket in byStatus) byStatus[bucket] += 1;
|
|
936
|
+
const path2 = routeKey(e.path);
|
|
937
|
+
const key = `${e.method} ${path2}`;
|
|
938
|
+
const group = groups.get(key) ?? { method: e.method, path: path2, ms: [], errors: 0 };
|
|
939
|
+
group.ms.push(e.durationMs);
|
|
940
|
+
if (e.status >= 500) group.errors += 1;
|
|
941
|
+
groups.set(key, group);
|
|
942
|
+
}
|
|
943
|
+
const routes = [...groups.values()].map((g) => {
|
|
944
|
+
const sorted = [...g.ms].sort((a, b) => a - b);
|
|
945
|
+
return {
|
|
946
|
+
method: g.method,
|
|
947
|
+
path: g.path,
|
|
948
|
+
count: g.ms.length,
|
|
949
|
+
avgMs: g.ms.reduce((a, b) => a + b, 0) / g.ms.length,
|
|
950
|
+
p95Ms: percentile(sorted, 0.95),
|
|
951
|
+
errors: g.errors
|
|
952
|
+
};
|
|
953
|
+
}).sort((a, b) => b.p95Ms - a.p95Ms).slice(0, MAX_ROUTE_STATS);
|
|
954
|
+
return {
|
|
955
|
+
count: entries.length,
|
|
956
|
+
errors: byStatus["5xx"],
|
|
957
|
+
errorRate: entries.length === 0 ? 0 : byStatus["5xx"] / entries.length,
|
|
958
|
+
avgMs: durations.length === 0 ? 0 : durations.reduce((a, b) => a + b, 0) / durations.length,
|
|
959
|
+
p50Ms: percentile(durations, 0.5),
|
|
960
|
+
p95Ms: percentile(durations, 0.95),
|
|
961
|
+
p99Ms: percentile(durations, 0.99),
|
|
962
|
+
byStatus,
|
|
963
|
+
routes
|
|
964
|
+
};
|
|
965
|
+
}
|
|
507
966
|
var RequestsProvider = class {
|
|
508
967
|
id = "requests";
|
|
509
968
|
buffer;
|
|
@@ -519,8 +978,9 @@ var RequestsProvider = class {
|
|
|
519
978
|
return this.buffer.length;
|
|
520
979
|
}
|
|
521
980
|
get() {
|
|
522
|
-
const entries = this.buffer.slice(Math.max(0, this.buffer.length -
|
|
523
|
-
|
|
981
|
+
const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE2));
|
|
982
|
+
const summary = summarizeRequests(this.buffer.toArray());
|
|
983
|
+
return { ok: true, data: { total: this.buffer.length, entries, summary } };
|
|
524
984
|
}
|
|
525
985
|
};
|
|
526
986
|
|
|
@@ -634,6 +1094,11 @@ var RoutesProvider = class {
|
|
|
634
1094
|
id = "routes";
|
|
635
1095
|
cache = /* @__PURE__ */ new WeakMap();
|
|
636
1096
|
get(ctx) {
|
|
1097
|
+
const source = ctx.store["routes-source"];
|
|
1098
|
+
if (source !== void 0) {
|
|
1099
|
+
const routes2 = typeof source === "function" ? source() : source;
|
|
1100
|
+
return { ok: true, data: { routes: routes2 } };
|
|
1101
|
+
}
|
|
637
1102
|
const router = ctx.store["express-router"];
|
|
638
1103
|
if (!router) {
|
|
639
1104
|
return {
|
|
@@ -682,14 +1147,14 @@ function formatArg(arg) {
|
|
|
682
1147
|
var refs = 0;
|
|
683
1148
|
var pushes = /* @__PURE__ */ new Set();
|
|
684
1149
|
var wrappers = /* @__PURE__ */ new Map();
|
|
685
|
-
var
|
|
1150
|
+
var originals2 = {};
|
|
686
1151
|
function consoleRef() {
|
|
687
1152
|
return console;
|
|
688
1153
|
}
|
|
689
1154
|
function installWrappers() {
|
|
690
1155
|
for (const [method, level] of CONSOLE_METHODS) {
|
|
691
1156
|
const original = consoleRef()[method];
|
|
692
|
-
|
|
1157
|
+
originals2[method] = original;
|
|
693
1158
|
const wrapper = (...args) => {
|
|
694
1159
|
for (const push of pushes) {
|
|
695
1160
|
try {
|
|
@@ -707,7 +1172,7 @@ function uninstallWrappers() {
|
|
|
707
1172
|
for (const [method] of CONSOLE_METHODS) {
|
|
708
1173
|
const current = consoleRef()[method];
|
|
709
1174
|
if (current === wrappers.get(method)) {
|
|
710
|
-
const original =
|
|
1175
|
+
const original = originals2[method];
|
|
711
1176
|
if (original) {
|
|
712
1177
|
consoleRef()[method] = original;
|
|
713
1178
|
} else {
|
|
@@ -716,7 +1181,7 @@ function uninstallWrappers() {
|
|
|
716
1181
|
}
|
|
717
1182
|
}
|
|
718
1183
|
wrappers.clear();
|
|
719
|
-
|
|
1184
|
+
originals2 = {};
|
|
720
1185
|
}
|
|
721
1186
|
function interceptConsole(push) {
|
|
722
1187
|
refs += 1;
|
|
@@ -763,9 +1228,10 @@ var LogsProvider = class {
|
|
|
763
1228
|
};
|
|
764
1229
|
|
|
765
1230
|
// src/sse.ts
|
|
766
|
-
function startSse(res) {
|
|
1231
|
+
function startSse(res, extraHeaders = {}) {
|
|
767
1232
|
let closed = false;
|
|
768
1233
|
res.writeHead(200, {
|
|
1234
|
+
...extraHeaders,
|
|
769
1235
|
"Content-Type": "text/event-stream",
|
|
770
1236
|
"Cache-Control": "no-cache",
|
|
771
1237
|
Connection: "keep-alive",
|
|
@@ -793,11 +1259,38 @@ function startSse(res) {
|
|
|
793
1259
|
|
|
794
1260
|
// src/server.ts
|
|
795
1261
|
import { tmpdir as tmpdir2 } from "os";
|
|
796
|
-
import { resolve as resolve2 } from "path";
|
|
1262
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
797
1263
|
var STATIC_ROOT = resolve2(__dirname, "..", "static");
|
|
798
1264
|
var SSE_HEARTBEAT_MS = 15e3;
|
|
799
|
-
function serializeEnvelope(envelope) {
|
|
800
|
-
return JSON.stringify(maskSecrets(envelope));
|
|
1265
|
+
function serializeEnvelope(envelope, mask = true) {
|
|
1266
|
+
return JSON.stringify(mask ? maskSecrets(envelope) : envelope);
|
|
1267
|
+
}
|
|
1268
|
+
var BUILT_IN_PANELS = /* @__PURE__ */ new Set([
|
|
1269
|
+
"health",
|
|
1270
|
+
"memory",
|
|
1271
|
+
"cpu",
|
|
1272
|
+
"event-loop",
|
|
1273
|
+
"heap-snapshot",
|
|
1274
|
+
"startup",
|
|
1275
|
+
"requests",
|
|
1276
|
+
"env",
|
|
1277
|
+
"routes",
|
|
1278
|
+
"logs",
|
|
1279
|
+
"metrics",
|
|
1280
|
+
"outgoing"
|
|
1281
|
+
]);
|
|
1282
|
+
var PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
1283
|
+
var RESERVED_API_NAMES = /* @__PURE__ */ new Set(["config", "live", "confirmations"]);
|
|
1284
|
+
var SECURITY_HEADERS = {
|
|
1285
|
+
"X-Content-Type-Options": "nosniff",
|
|
1286
|
+
"X-Frame-Options": "DENY",
|
|
1287
|
+
"Referrer-Policy": "no-referrer",
|
|
1288
|
+
"Cross-Origin-Resource-Policy": "same-origin"
|
|
1289
|
+
};
|
|
1290
|
+
var CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
|
|
1291
|
+
function listFrom(value, raw) {
|
|
1292
|
+
if (value) return value;
|
|
1293
|
+
return (raw ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
801
1294
|
}
|
|
802
1295
|
function pathnameOf(req) {
|
|
803
1296
|
const raw = req.originalUrl ?? req.url ?? "/";
|
|
@@ -857,19 +1350,46 @@ function createNodeUI(options = {}) {
|
|
|
857
1350
|
env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
|
|
858
1351
|
"NODEUI_CONFIRM_TTL_MS"
|
|
859
1352
|
),
|
|
860
|
-
heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? tmpdir2()
|
|
1353
|
+
heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? join3(tmpdir2(), "nodeui-heap"),
|
|
1354
|
+
allowedHosts: listFrom(options.allowedHosts, env.NODEUI_ALLOWED_HOSTS),
|
|
1355
|
+
allowedOrigins: listFrom(options.allowedOrigins, env.NODEUI_ALLOWED_ORIGINS),
|
|
1356
|
+
allowedRemoteAddresses: listFrom(options.allowedRemoteAddresses, env.NODEUI_ALLOWED_REMOTE),
|
|
1357
|
+
trustProxy: options.trustProxy ?? env.NODEUI_TRUST_PROXY === "true",
|
|
1358
|
+
authRequired: Boolean(options.authToken ?? env.NODEUI_TOKEN),
|
|
1359
|
+
maxSseClients: positiveInt(
|
|
1360
|
+
options.maxSseClients,
|
|
1361
|
+
env.NODEUI_MAX_SSE_CLIENTS ? Number(env.NODEUI_MAX_SSE_CLIENTS) : 10,
|
|
1362
|
+
"NODEUI_MAX_SSE_CLIENTS"
|
|
1363
|
+
)
|
|
861
1364
|
};
|
|
862
|
-
|
|
1365
|
+
const authToken = options.authToken ?? env.NODEUI_TOKEN;
|
|
1366
|
+
const guard = createGuard({
|
|
1367
|
+
host: config.host,
|
|
1368
|
+
allowedHosts: config.allowedHosts,
|
|
1369
|
+
allowedOrigins: config.allowedOrigins,
|
|
1370
|
+
allowedRemoteAddresses: config.allowedRemoteAddresses,
|
|
1371
|
+
trustProxy: config.trustProxy,
|
|
1372
|
+
authToken: authToken || void 0
|
|
1373
|
+
});
|
|
1374
|
+
if (config.enabled && !isLoopbackAddress(config.host)) {
|
|
863
1375
|
console.warn(
|
|
864
|
-
`[nodeui] NODEUI_HOST is set to non-loopback "${config.host}". The developer console will be reachable from other hosts; only do this deliberately
|
|
1376
|
+
`[nodeui] NODEUI_HOST is set to non-loopback "${config.host}". The developer console will be reachable from other hosts; only do this deliberately` + (config.authRequired ? "." : " and set NODEUI_TOKEN (authToken) to require a token.")
|
|
865
1377
|
);
|
|
866
1378
|
}
|
|
1379
|
+
if (config.enabled && env.NODE_ENV === "production") {
|
|
1380
|
+
console.warn(
|
|
1381
|
+
"[nodeui] running with NODE_ENV=production. The console exposes environment, logs and heap data; keep it behind loopback and an auth token."
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
if (config.enabled && !config.maskSecrets) {
|
|
1385
|
+
console.warn("[nodeui] secret masking is disabled; panels may show credentials.");
|
|
1386
|
+
}
|
|
867
1387
|
const ctx = { config, env, store: {} };
|
|
868
1388
|
if (options.config !== void 0) {
|
|
869
1389
|
ctx.store["app-config"] = options.config;
|
|
870
1390
|
}
|
|
871
1391
|
const registry = new ProviderRegistry();
|
|
872
|
-
registry.register(new HealthProvider());
|
|
1392
|
+
registry.register(new HealthProvider(options.healthChecks));
|
|
873
1393
|
registry.register(new MemoryProvider());
|
|
874
1394
|
registry.register(new CpuProvider());
|
|
875
1395
|
registry.register(new EventLoopLagProvider());
|
|
@@ -891,9 +1411,23 @@ function createNodeUI(options = {}) {
|
|
|
891
1411
|
registry.register(envProvider);
|
|
892
1412
|
const routesProvider = new RoutesProvider();
|
|
893
1413
|
registry.register(routesProvider);
|
|
1414
|
+
registry.register(
|
|
1415
|
+
new OutgoingProvider(positiveInt(options.outgoingLogSize, 200, "outgoingLogSize"))
|
|
1416
|
+
);
|
|
1417
|
+
const pluginMeta = [];
|
|
1418
|
+
for (const plugin of options.plugins ?? []) {
|
|
1419
|
+
if (!PLUGIN_ID_PATTERN.test(plugin.id) || BUILT_IN_PANELS.has(plugin.id) || RESERVED_API_NAMES.has(plugin.id) || registry.get(plugin.id)) {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`nodeui plugin id "${plugin.id}" is invalid or already taken (use lowercase letters, digits and "-")`
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
registry.register(plugin);
|
|
1425
|
+
pluginMeta.push({ id: plugin.id, title: plugin.title ?? plugin.id });
|
|
1426
|
+
}
|
|
894
1427
|
const confirmations = new ConfirmationStore(config.confirmTtlMs);
|
|
895
1428
|
const activeProviders = /* @__PURE__ */ new Set();
|
|
896
1429
|
let inactivityTimer = null;
|
|
1430
|
+
let sseClients = 0;
|
|
897
1431
|
function stopAll() {
|
|
898
1432
|
for (const id of activeProviders) {
|
|
899
1433
|
registry.get(id)?.stop?.(ctx);
|
|
@@ -917,8 +1451,9 @@ function createNodeUI(options = {}) {
|
|
|
917
1451
|
resetInactivityTimer();
|
|
918
1452
|
}
|
|
919
1453
|
function sendJson(res, status, envelope) {
|
|
920
|
-
const body = serializeEnvelope(envelope);
|
|
1454
|
+
const body = serializeEnvelope(envelope, config.maskSecrets);
|
|
921
1455
|
res.writeHead(status, {
|
|
1456
|
+
...SECURITY_HEADERS,
|
|
922
1457
|
"Content-Type": "application/json",
|
|
923
1458
|
"Content-Length": Buffer.byteLength(body),
|
|
924
1459
|
"Cache-Control": "no-store"
|
|
@@ -936,8 +1471,24 @@ function createNodeUI(options = {}) {
|
|
|
936
1471
|
const provider = registry.get(id);
|
|
937
1472
|
if (!provider) return notFound(res);
|
|
938
1473
|
const requestCtx = query ? { ...ctx, query } : ctx;
|
|
939
|
-
|
|
940
|
-
|
|
1474
|
+
sendJson(res, ...await readPanel(provider, requestCtx));
|
|
1475
|
+
}
|
|
1476
|
+
async function readPanel(provider, requestCtx) {
|
|
1477
|
+
try {
|
|
1478
|
+
const result = await provider.get(requestCtx);
|
|
1479
|
+
return [result.ok ? 200 : 500, result];
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
return [
|
|
1482
|
+
500,
|
|
1483
|
+
{
|
|
1484
|
+
ok: false,
|
|
1485
|
+
error: {
|
|
1486
|
+
code: "provider-failed",
|
|
1487
|
+
message: err instanceof Error ? err.message : "provider failed"
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
];
|
|
1491
|
+
}
|
|
941
1492
|
}
|
|
942
1493
|
function configData() {
|
|
943
1494
|
return {
|
|
@@ -950,7 +1501,9 @@ function createNodeUI(options = {}) {
|
|
|
950
1501
|
logSize: config.logSize,
|
|
951
1502
|
pollIntervalMs: config.pollIntervalMs,
|
|
952
1503
|
panels: registry.ids(),
|
|
953
|
-
masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source }
|
|
1504
|
+
masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source },
|
|
1505
|
+
locked: config.authRequired,
|
|
1506
|
+
plugins: pluginMeta
|
|
954
1507
|
};
|
|
955
1508
|
}
|
|
956
1509
|
function issueConfirmation(res) {
|
|
@@ -982,22 +1535,37 @@ function createNodeUI(options = {}) {
|
|
|
982
1535
|
const requested = (search.get("panels") ?? "").split(",").filter(Boolean);
|
|
983
1536
|
const panels = (requested.length > 0 ? requested.filter((id) => registry.get(id) !== void 0) : registry.ids()).filter((value, index, self) => self.indexOf(value) === index);
|
|
984
1537
|
if (panels.length === 0) return notFound(res);
|
|
985
|
-
|
|
1538
|
+
if (sseClients >= config.maxSseClients) {
|
|
1539
|
+
res.writeHead(429, { ...SECURITY_HEADERS, "Content-Type": "application/json" });
|
|
1540
|
+
res.end(
|
|
1541
|
+
JSON.stringify({
|
|
1542
|
+
ok: false,
|
|
1543
|
+
error: { code: "too-many-streams", message: "too many open live streams" }
|
|
1544
|
+
})
|
|
1545
|
+
);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
sseClients += 1;
|
|
1549
|
+
const stream = startSse(res, SECURITY_HEADERS);
|
|
986
1550
|
const push = async () => {
|
|
987
1551
|
for (const id of panels) {
|
|
988
1552
|
ensureActive(id);
|
|
989
1553
|
const provider = registry.get(id);
|
|
990
1554
|
if (!provider) continue;
|
|
991
|
-
const result = await provider
|
|
992
|
-
stream.send({ panel: id, envelope: result });
|
|
1555
|
+
const [, result] = await readPanel(provider, ctx);
|
|
1556
|
+
stream.send({ panel: id, envelope: config.maskSecrets ? maskSecrets(result) : result });
|
|
993
1557
|
}
|
|
994
1558
|
};
|
|
995
|
-
void push();
|
|
996
|
-
const pushTimer = setInterval(() => void push(), config.pollIntervalMs);
|
|
1559
|
+
void push().catch(() => void 0);
|
|
1560
|
+
const pushTimer = setInterval(() => void push().catch(() => void 0), config.pollIntervalMs);
|
|
997
1561
|
const heartbeatTimer = setInterval(() => stream.heartbeat(), SSE_HEARTBEAT_MS);
|
|
998
1562
|
if (typeof pushTimer.unref === "function") pushTimer.unref();
|
|
999
1563
|
if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
|
|
1564
|
+
let cleaned = false;
|
|
1000
1565
|
const cleanup = () => {
|
|
1566
|
+
if (cleaned) return;
|
|
1567
|
+
cleaned = true;
|
|
1568
|
+
sseClients -= 1;
|
|
1001
1569
|
clearInterval(pushTimer);
|
|
1002
1570
|
clearInterval(heartbeatTimer);
|
|
1003
1571
|
stream.close();
|
|
@@ -1040,30 +1608,33 @@ function createNodeUI(options = {}) {
|
|
|
1040
1608
|
return;
|
|
1041
1609
|
}
|
|
1042
1610
|
res.writeHead(200, {
|
|
1611
|
+
...SECURITY_HEADERS,
|
|
1612
|
+
"Content-Security-Policy": CSP,
|
|
1043
1613
|
"Content-Type": asset.contentType,
|
|
1044
1614
|
"Content-Length": asset.length,
|
|
1045
1615
|
"Cache-Control": "no-cache"
|
|
1046
1616
|
});
|
|
1047
1617
|
asset.content.pipe(res);
|
|
1048
1618
|
}
|
|
1049
|
-
function guardLoopback(req, res) {
|
|
1050
|
-
if (isLoopbackAddress(config.host)) {
|
|
1051
|
-
const remote = req.socket.remoteAddress;
|
|
1052
|
-
if (!isLoopbackAddress(remote)) {
|
|
1053
|
-
sendJson(res, 403, {
|
|
1054
|
-
ok: false,
|
|
1055
|
-
error: {
|
|
1056
|
-
code: "forbidden",
|
|
1057
|
-
message: "nodeui is loopback-only; requests from other hosts are rejected"
|
|
1058
|
-
}
|
|
1059
|
-
});
|
|
1060
|
-
return false;
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
return true;
|
|
1064
|
-
}
|
|
1065
1619
|
async function handleNodeUIPath(req, res, urlPath) {
|
|
1066
|
-
|
|
1620
|
+
const verdict = guard(req);
|
|
1621
|
+
if (!verdict.ok) {
|
|
1622
|
+
sendJson(res, verdict.status, {
|
|
1623
|
+
ok: false,
|
|
1624
|
+
error: { code: verdict.code, message: verdict.message }
|
|
1625
|
+
});
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
if (verdict.setToken !== void 0) {
|
|
1629
|
+
res.writeHead(302, {
|
|
1630
|
+
...SECURITY_HEADERS,
|
|
1631
|
+
"Set-Cookie": `${AUTH_COOKIE}=${encodeURIComponent(verdict.setToken)}; Path=${path2}; HttpOnly; SameSite=Strict`,
|
|
1632
|
+
Location: `${path2}/`,
|
|
1633
|
+
"Cache-Control": "no-store"
|
|
1634
|
+
});
|
|
1635
|
+
res.end();
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1067
1638
|
const apiBase = `${path2}/api`;
|
|
1068
1639
|
if (urlPath === apiBase || urlPath.startsWith(`${apiBase}/`)) {
|
|
1069
1640
|
await handleApi(req, res, urlPath.slice(apiBase.length));
|
|
@@ -1147,6 +1718,9 @@ function createNodeUI(options = {}) {
|
|
|
1147
1718
|
}
|
|
1148
1719
|
notFound(res);
|
|
1149
1720
|
},
|
|
1721
|
+
setRoutes(source) {
|
|
1722
|
+
ctx.store["routes-source"] = source;
|
|
1723
|
+
},
|
|
1150
1724
|
mark(name) {
|
|
1151
1725
|
startupTracker.mark(name);
|
|
1152
1726
|
},
|
|
@@ -1163,6 +1737,7 @@ function createNodeUI(options = {}) {
|
|
|
1163
1737
|
return server;
|
|
1164
1738
|
}
|
|
1165
1739
|
export {
|
|
1740
|
+
AUTH_COOKIE,
|
|
1166
1741
|
ConfirmationStore,
|
|
1167
1742
|
CpuProvider,
|
|
1168
1743
|
DEFAULT_LOG_SIZE,
|
|
@@ -1173,6 +1748,7 @@ export {
|
|
|
1173
1748
|
LogsProvider,
|
|
1174
1749
|
MemoryProvider,
|
|
1175
1750
|
MetricsProvider,
|
|
1751
|
+
OutgoingProvider,
|
|
1176
1752
|
ProviderRegistry,
|
|
1177
1753
|
RequestsProvider,
|
|
1178
1754
|
RingBuffer,
|
|
@@ -1181,14 +1757,21 @@ export {
|
|
|
1181
1757
|
SECRET_MASKED,
|
|
1182
1758
|
Sampler,
|
|
1183
1759
|
StartupTracker,
|
|
1760
|
+
createGuard,
|
|
1184
1761
|
createNodeUI,
|
|
1185
1762
|
extractRoutes,
|
|
1763
|
+
hostnameFromHostHeader,
|
|
1186
1764
|
interceptConsole,
|
|
1765
|
+
interceptOutgoing,
|
|
1187
1766
|
isLoopbackAddress,
|
|
1767
|
+
isLoopbackHostname,
|
|
1768
|
+
maskSecretText,
|
|
1188
1769
|
maskSecrets,
|
|
1770
|
+
matchesAddress,
|
|
1189
1771
|
resolveActivation,
|
|
1190
1772
|
resolveStaticAsset,
|
|
1191
1773
|
serializeEnvelope,
|
|
1192
|
-
startSse
|
|
1774
|
+
startSse,
|
|
1775
|
+
summarizeRequests
|
|
1193
1776
|
};
|
|
1194
1777
|
//# sourceMappingURL=index.mjs.map
|