@singhak/nodeui-core 0.1.0 → 0.3.0

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
685
+ }
686
+ };
687
+ }
688
+ };
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}` };
457
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;
458
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 } };
459
869
  }
460
870
  };
461
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,7 @@ 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;
507
918
  var RequestsProvider = class {
508
919
  id = "requests";
509
920
  buffer;
@@ -519,7 +930,7 @@ var RequestsProvider = class {
519
930
  return this.buffer.length;
520
931
  }
521
932
  get() {
522
- const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE));
933
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE2));
523
934
  return { ok: true, data: { total: this.buffer.length, entries } };
524
935
  }
525
936
  };
@@ -634,6 +1045,11 @@ var RoutesProvider = class {
634
1045
  id = "routes";
635
1046
  cache = /* @__PURE__ */ new WeakMap();
636
1047
  get(ctx) {
1048
+ const source = ctx.store["routes-source"];
1049
+ if (source !== void 0) {
1050
+ const routes2 = typeof source === "function" ? source() : source;
1051
+ return { ok: true, data: { routes: routes2 } };
1052
+ }
637
1053
  const router = ctx.store["express-router"];
638
1054
  if (!router) {
639
1055
  return {
@@ -682,14 +1098,14 @@ function formatArg(arg) {
682
1098
  var refs = 0;
683
1099
  var pushes = /* @__PURE__ */ new Set();
684
1100
  var wrappers = /* @__PURE__ */ new Map();
685
- var originals = {};
1101
+ var originals2 = {};
686
1102
  function consoleRef() {
687
1103
  return console;
688
1104
  }
689
1105
  function installWrappers() {
690
1106
  for (const [method, level] of CONSOLE_METHODS) {
691
1107
  const original = consoleRef()[method];
692
- originals[method] = original;
1108
+ originals2[method] = original;
693
1109
  const wrapper = (...args) => {
694
1110
  for (const push of pushes) {
695
1111
  try {
@@ -707,7 +1123,7 @@ function uninstallWrappers() {
707
1123
  for (const [method] of CONSOLE_METHODS) {
708
1124
  const current = consoleRef()[method];
709
1125
  if (current === wrappers.get(method)) {
710
- const original = originals[method];
1126
+ const original = originals2[method];
711
1127
  if (original) {
712
1128
  consoleRef()[method] = original;
713
1129
  } else {
@@ -716,7 +1132,7 @@ function uninstallWrappers() {
716
1132
  }
717
1133
  }
718
1134
  wrappers.clear();
719
- originals = {};
1135
+ originals2 = {};
720
1136
  }
721
1137
  function interceptConsole(push) {
722
1138
  refs += 1;
@@ -763,9 +1179,10 @@ var LogsProvider = class {
763
1179
  };
764
1180
 
765
1181
  // src/sse.ts
766
- function startSse(res) {
1182
+ function startSse(res, extraHeaders = {}) {
767
1183
  let closed = false;
768
1184
  res.writeHead(200, {
1185
+ ...extraHeaders,
769
1186
  "Content-Type": "text/event-stream",
770
1187
  "Cache-Control": "no-cache",
771
1188
  Connection: "keep-alive",
@@ -793,11 +1210,38 @@ function startSse(res) {
793
1210
 
794
1211
  // src/server.ts
795
1212
  import { tmpdir as tmpdir2 } from "os";
796
- import { resolve as resolve2 } from "path";
1213
+ import { join as join3, resolve as resolve2 } from "path";
797
1214
  var STATIC_ROOT = resolve2(__dirname, "..", "static");
798
1215
  var SSE_HEARTBEAT_MS = 15e3;
799
- function serializeEnvelope(envelope) {
800
- return JSON.stringify(maskSecrets(envelope));
1216
+ function serializeEnvelope(envelope, mask = true) {
1217
+ return JSON.stringify(mask ? maskSecrets(envelope) : envelope);
1218
+ }
1219
+ var BUILT_IN_PANELS = /* @__PURE__ */ new Set([
1220
+ "health",
1221
+ "memory",
1222
+ "cpu",
1223
+ "event-loop",
1224
+ "heap-snapshot",
1225
+ "startup",
1226
+ "requests",
1227
+ "env",
1228
+ "routes",
1229
+ "logs",
1230
+ "metrics",
1231
+ "outgoing"
1232
+ ]);
1233
+ var PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1234
+ var RESERVED_API_NAMES = /* @__PURE__ */ new Set(["config", "live", "confirmations"]);
1235
+ var SECURITY_HEADERS = {
1236
+ "X-Content-Type-Options": "nosniff",
1237
+ "X-Frame-Options": "DENY",
1238
+ "Referrer-Policy": "no-referrer",
1239
+ "Cross-Origin-Resource-Policy": "same-origin"
1240
+ };
1241
+ 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'";
1242
+ function listFrom(value, raw) {
1243
+ if (value) return value;
1244
+ return (raw ?? "").split(",").map((v) => v.trim()).filter(Boolean);
801
1245
  }
802
1246
  function pathnameOf(req) {
803
1247
  const raw = req.originalUrl ?? req.url ?? "/";
@@ -857,19 +1301,46 @@ function createNodeUI(options = {}) {
857
1301
  env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
858
1302
  "NODEUI_CONFIRM_TTL_MS"
859
1303
  ),
860
- heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? tmpdir2()
1304
+ heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? join3(tmpdir2(), "nodeui-heap"),
1305
+ allowedHosts: listFrom(options.allowedHosts, env.NODEUI_ALLOWED_HOSTS),
1306
+ allowedOrigins: listFrom(options.allowedOrigins, env.NODEUI_ALLOWED_ORIGINS),
1307
+ allowedRemoteAddresses: listFrom(options.allowedRemoteAddresses, env.NODEUI_ALLOWED_REMOTE),
1308
+ trustProxy: options.trustProxy ?? env.NODEUI_TRUST_PROXY === "true",
1309
+ authRequired: Boolean(options.authToken ?? env.NODEUI_TOKEN),
1310
+ maxSseClients: positiveInt(
1311
+ options.maxSseClients,
1312
+ env.NODEUI_MAX_SSE_CLIENTS ? Number(env.NODEUI_MAX_SSE_CLIENTS) : 10,
1313
+ "NODEUI_MAX_SSE_CLIENTS"
1314
+ )
861
1315
  };
862
- if (!isLoopbackAddress(config.host)) {
1316
+ const authToken = options.authToken ?? env.NODEUI_TOKEN;
1317
+ const guard = createGuard({
1318
+ host: config.host,
1319
+ allowedHosts: config.allowedHosts,
1320
+ allowedOrigins: config.allowedOrigins,
1321
+ allowedRemoteAddresses: config.allowedRemoteAddresses,
1322
+ trustProxy: config.trustProxy,
1323
+ authToken: authToken || void 0
1324
+ });
1325
+ if (config.enabled && !isLoopbackAddress(config.host)) {
1326
+ console.warn(
1327
+ `[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.")
1328
+ );
1329
+ }
1330
+ if (config.enabled && env.NODE_ENV === "production") {
863
1331
  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.`
1332
+ "[nodeui] running with NODE_ENV=production. The console exposes environment, logs and heap data; keep it behind loopback and an auth token."
865
1333
  );
866
1334
  }
1335
+ if (config.enabled && !config.maskSecrets) {
1336
+ console.warn("[nodeui] secret masking is disabled; panels may show credentials.");
1337
+ }
867
1338
  const ctx = { config, env, store: {} };
868
1339
  if (options.config !== void 0) {
869
1340
  ctx.store["app-config"] = options.config;
870
1341
  }
871
1342
  const registry = new ProviderRegistry();
872
- registry.register(new HealthProvider());
1343
+ registry.register(new HealthProvider(options.healthChecks));
873
1344
  registry.register(new MemoryProvider());
874
1345
  registry.register(new CpuProvider());
875
1346
  registry.register(new EventLoopLagProvider());
@@ -891,9 +1362,23 @@ function createNodeUI(options = {}) {
891
1362
  registry.register(envProvider);
892
1363
  const routesProvider = new RoutesProvider();
893
1364
  registry.register(routesProvider);
1365
+ registry.register(
1366
+ new OutgoingProvider(positiveInt(options.outgoingLogSize, 200, "outgoingLogSize"))
1367
+ );
1368
+ const pluginMeta = [];
1369
+ for (const plugin of options.plugins ?? []) {
1370
+ if (!PLUGIN_ID_PATTERN.test(plugin.id) || BUILT_IN_PANELS.has(plugin.id) || RESERVED_API_NAMES.has(plugin.id) || registry.get(plugin.id)) {
1371
+ throw new Error(
1372
+ `nodeui plugin id "${plugin.id}" is invalid or already taken (use lowercase letters, digits and "-")`
1373
+ );
1374
+ }
1375
+ registry.register(plugin);
1376
+ pluginMeta.push({ id: plugin.id, title: plugin.title ?? plugin.id });
1377
+ }
894
1378
  const confirmations = new ConfirmationStore(config.confirmTtlMs);
895
1379
  const activeProviders = /* @__PURE__ */ new Set();
896
1380
  let inactivityTimer = null;
1381
+ let sseClients = 0;
897
1382
  function stopAll() {
898
1383
  for (const id of activeProviders) {
899
1384
  registry.get(id)?.stop?.(ctx);
@@ -917,8 +1402,9 @@ function createNodeUI(options = {}) {
917
1402
  resetInactivityTimer();
918
1403
  }
919
1404
  function sendJson(res, status, envelope) {
920
- const body = serializeEnvelope(envelope);
1405
+ const body = serializeEnvelope(envelope, config.maskSecrets);
921
1406
  res.writeHead(status, {
1407
+ ...SECURITY_HEADERS,
922
1408
  "Content-Type": "application/json",
923
1409
  "Content-Length": Buffer.byteLength(body),
924
1410
  "Cache-Control": "no-store"
@@ -936,8 +1422,24 @@ function createNodeUI(options = {}) {
936
1422
  const provider = registry.get(id);
937
1423
  if (!provider) return notFound(res);
938
1424
  const requestCtx = query ? { ...ctx, query } : ctx;
939
- const result = await provider.get(requestCtx);
940
- sendJson(res, result.ok ? 200 : 500, result);
1425
+ sendJson(res, ...await readPanel(provider, requestCtx));
1426
+ }
1427
+ async function readPanel(provider, requestCtx) {
1428
+ try {
1429
+ const result = await provider.get(requestCtx);
1430
+ return [result.ok ? 200 : 500, result];
1431
+ } catch (err) {
1432
+ return [
1433
+ 500,
1434
+ {
1435
+ ok: false,
1436
+ error: {
1437
+ code: "provider-failed",
1438
+ message: err instanceof Error ? err.message : "provider failed"
1439
+ }
1440
+ }
1441
+ ];
1442
+ }
941
1443
  }
942
1444
  function configData() {
943
1445
  return {
@@ -950,7 +1452,9 @@ function createNodeUI(options = {}) {
950
1452
  logSize: config.logSize,
951
1453
  pollIntervalMs: config.pollIntervalMs,
952
1454
  panels: registry.ids(),
953
- masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source }
1455
+ masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source },
1456
+ authRequired: config.authRequired,
1457
+ plugins: pluginMeta
954
1458
  };
955
1459
  }
956
1460
  function issueConfirmation(res) {
@@ -982,22 +1486,37 @@ function createNodeUI(options = {}) {
982
1486
  const requested = (search.get("panels") ?? "").split(",").filter(Boolean);
983
1487
  const panels = (requested.length > 0 ? requested.filter((id) => registry.get(id) !== void 0) : registry.ids()).filter((value, index, self) => self.indexOf(value) === index);
984
1488
  if (panels.length === 0) return notFound(res);
985
- const stream = startSse(res);
1489
+ if (sseClients >= config.maxSseClients) {
1490
+ res.writeHead(429, { ...SECURITY_HEADERS, "Content-Type": "application/json" });
1491
+ res.end(
1492
+ JSON.stringify({
1493
+ ok: false,
1494
+ error: { code: "too-many-streams", message: "too many open live streams" }
1495
+ })
1496
+ );
1497
+ return;
1498
+ }
1499
+ sseClients += 1;
1500
+ const stream = startSse(res, SECURITY_HEADERS);
986
1501
  const push = async () => {
987
1502
  for (const id of panels) {
988
1503
  ensureActive(id);
989
1504
  const provider = registry.get(id);
990
1505
  if (!provider) continue;
991
- const result = await provider.get(ctx);
992
- stream.send({ panel: id, envelope: result });
1506
+ const [, result] = await readPanel(provider, ctx);
1507
+ stream.send({ panel: id, envelope: config.maskSecrets ? maskSecrets(result) : result });
993
1508
  }
994
1509
  };
995
- void push();
996
- const pushTimer = setInterval(() => void push(), config.pollIntervalMs);
1510
+ void push().catch(() => void 0);
1511
+ const pushTimer = setInterval(() => void push().catch(() => void 0), config.pollIntervalMs);
997
1512
  const heartbeatTimer = setInterval(() => stream.heartbeat(), SSE_HEARTBEAT_MS);
998
1513
  if (typeof pushTimer.unref === "function") pushTimer.unref();
999
1514
  if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
1515
+ let cleaned = false;
1000
1516
  const cleanup = () => {
1517
+ if (cleaned) return;
1518
+ cleaned = true;
1519
+ sseClients -= 1;
1001
1520
  clearInterval(pushTimer);
1002
1521
  clearInterval(heartbeatTimer);
1003
1522
  stream.close();
@@ -1040,30 +1559,33 @@ function createNodeUI(options = {}) {
1040
1559
  return;
1041
1560
  }
1042
1561
  res.writeHead(200, {
1562
+ ...SECURITY_HEADERS,
1563
+ "Content-Security-Policy": CSP,
1043
1564
  "Content-Type": asset.contentType,
1044
1565
  "Content-Length": asset.length,
1045
1566
  "Cache-Control": "no-cache"
1046
1567
  });
1047
1568
  asset.content.pipe(res);
1048
1569
  }
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
1570
  async function handleNodeUIPath(req, res, urlPath) {
1066
- if (!guardLoopback(req, res)) return;
1571
+ const verdict = guard(req);
1572
+ if (!verdict.ok) {
1573
+ sendJson(res, verdict.status, {
1574
+ ok: false,
1575
+ error: { code: verdict.code, message: verdict.message }
1576
+ });
1577
+ return;
1578
+ }
1579
+ if (verdict.setToken !== void 0) {
1580
+ res.writeHead(302, {
1581
+ ...SECURITY_HEADERS,
1582
+ "Set-Cookie": `${AUTH_COOKIE}=${encodeURIComponent(verdict.setToken)}; Path=${path2}; HttpOnly; SameSite=Strict`,
1583
+ Location: `${path2}/`,
1584
+ "Cache-Control": "no-store"
1585
+ });
1586
+ res.end();
1587
+ return;
1588
+ }
1067
1589
  const apiBase = `${path2}/api`;
1068
1590
  if (urlPath === apiBase || urlPath.startsWith(`${apiBase}/`)) {
1069
1591
  await handleApi(req, res, urlPath.slice(apiBase.length));
@@ -1147,6 +1669,9 @@ function createNodeUI(options = {}) {
1147
1669
  }
1148
1670
  notFound(res);
1149
1671
  },
1672
+ setRoutes(source) {
1673
+ ctx.store["routes-source"] = source;
1674
+ },
1150
1675
  mark(name) {
1151
1676
  startupTracker.mark(name);
1152
1677
  },
@@ -1163,6 +1688,7 @@ function createNodeUI(options = {}) {
1163
1688
  return server;
1164
1689
  }
1165
1690
  export {
1691
+ AUTH_COOKIE,
1166
1692
  ConfirmationStore,
1167
1693
  CpuProvider,
1168
1694
  DEFAULT_LOG_SIZE,
@@ -1173,6 +1699,7 @@ export {
1173
1699
  LogsProvider,
1174
1700
  MemoryProvider,
1175
1701
  MetricsProvider,
1702
+ OutgoingProvider,
1176
1703
  ProviderRegistry,
1177
1704
  RequestsProvider,
1178
1705
  RingBuffer,
@@ -1181,11 +1708,17 @@ export {
1181
1708
  SECRET_MASKED,
1182
1709
  Sampler,
1183
1710
  StartupTracker,
1711
+ createGuard,
1184
1712
  createNodeUI,
1185
1713
  extractRoutes,
1714
+ hostnameFromHostHeader,
1186
1715
  interceptConsole,
1716
+ interceptOutgoing,
1187
1717
  isLoopbackAddress,
1718
+ isLoopbackHostname,
1719
+ maskSecretText,
1188
1720
  maskSecrets,
1721
+ matchesAddress,
1189
1722
  resolveActivation,
1190
1723
  resolveStaticAsset,
1191
1724
  serializeEnvelope,