@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/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|password|credential/i;
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
- get(ctx) {
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 RESPONSE_PAGE_SIZE = 100;
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 - RESPONSE_PAGE_SIZE));
523
- return { ok: true, data: { total: this.buffer.length, entries } };
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 originals = {};
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
- originals[method] = original;
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 = originals[method];
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
- originals = {};
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
- if (!isLoopbackAddress(config.host)) {
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
- const result = await provider.get(requestCtx);
940
- sendJson(res, result.ok ? 200 : 500, result);
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
- const stream = startSse(res);
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.get(ctx);
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
- if (!guardLoopback(req, res)) return;
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