@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.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,11 +17,20 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
21
31
  var index_exports = {};
22
32
  __export(index_exports, {
33
+ AUTH_COOKIE: () => AUTH_COOKIE,
23
34
  ConfirmationStore: () => ConfirmationStore,
24
35
  CpuProvider: () => CpuProvider,
25
36
  DEFAULT_LOG_SIZE: () => DEFAULT_LOG_SIZE,
@@ -30,6 +41,7 @@ __export(index_exports, {
30
41
  LogsProvider: () => LogsProvider,
31
42
  MemoryProvider: () => MemoryProvider,
32
43
  MetricsProvider: () => MetricsProvider,
44
+ OutgoingProvider: () => OutgoingProvider,
33
45
  ProviderRegistry: () => ProviderRegistry,
34
46
  RequestsProvider: () => RequestsProvider,
35
47
  RingBuffer: () => RingBuffer,
@@ -38,11 +50,17 @@ __export(index_exports, {
38
50
  SECRET_MASKED: () => SECRET_MASKED,
39
51
  Sampler: () => Sampler,
40
52
  StartupTracker: () => StartupTracker,
53
+ createGuard: () => createGuard,
41
54
  createNodeUI: () => createNodeUI,
42
55
  extractRoutes: () => extractRoutes,
56
+ hostnameFromHostHeader: () => hostnameFromHostHeader,
43
57
  interceptConsole: () => interceptConsole,
58
+ interceptOutgoing: () => interceptOutgoing,
44
59
  isLoopbackAddress: () => isLoopbackAddress,
60
+ isLoopbackHostname: () => isLoopbackHostname,
61
+ maskSecretText: () => maskSecretText,
45
62
  maskSecrets: () => maskSecrets,
63
+ matchesAddress: () => matchesAddress,
46
64
  resolveActivation: () => resolveActivation,
47
65
  resolveStaticAsset: () => resolveStaticAsset,
48
66
  serializeEnvelope: () => serializeEnvelope,
@@ -95,7 +113,15 @@ var RingBuffer = class {
95
113
  };
96
114
 
97
115
  // src/safety.ts
98
- var SECRET_KEY_PATTERN = /token|key|secret|password|credential/i;
116
+ 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;
117
+ var TEXT_PAIR_PATTERN = /\b([\w.-]*(?:token|secret|passw(?:or)?d|api[_-]?key|credential|authorization|cookie|dsn)[\w.-]*["']?)(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s,;&"'}]+)/gi;
118
+ var URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+(@)/gi;
119
+ var BEARER_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*/gi;
120
+ var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g;
121
+ function maskSecretText(text) {
122
+ if (text.length < 6) return text;
123
+ 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}`);
124
+ }
99
125
  function resolveActivation(env) {
100
126
  if (env.NODEUI_ENABLED === "true") {
101
127
  return { active: true, reason: "activated by NODEUI_ENABLED=true" };
@@ -118,7 +144,55 @@ function isLoopbackAddress(address) {
118
144
  if (!address) return false;
119
145
  return address.startsWith("127.") || address === "::1" || address.startsWith("::ffff:127.") || address === "localhost";
120
146
  }
147
+ function normalizeIp(address) {
148
+ return address.startsWith("::ffff:") ? address.slice(7) : address;
149
+ }
150
+ function ipv4ToInt(ip) {
151
+ const parts = ip.split(".");
152
+ if (parts.length !== 4) return null;
153
+ let out = 0;
154
+ for (const part of parts) {
155
+ const n = Number(part);
156
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null;
157
+ out = out * 256 + n;
158
+ }
159
+ return out;
160
+ }
161
+ function matchesAddress(address, allowed) {
162
+ if (!address) return false;
163
+ const ip = normalizeIp(address);
164
+ for (const entry of allowed) {
165
+ if (!entry.includes("/")) {
166
+ if (normalizeIp(entry) === ip) return true;
167
+ continue;
168
+ }
169
+ const [base, bitsRaw] = entry.split("/");
170
+ const bits = Number(bitsRaw);
171
+ const a = ipv4ToInt(ip);
172
+ const b = ipv4ToInt(base ?? "");
173
+ if (a === null || b === null || !Number.isInteger(bits) || bits < 0 || bits > 32) continue;
174
+ const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
175
+ if ((a & mask) >>> 0 === (b & mask) >>> 0) return true;
176
+ }
177
+ return false;
178
+ }
179
+ function hostnameFromHostHeader(value) {
180
+ if (!value) return null;
181
+ const trimmed = value.trim().toLowerCase();
182
+ if (trimmed.startsWith("[")) {
183
+ const end = trimmed.indexOf("]");
184
+ return end === -1 ? null : trimmed.slice(1, end);
185
+ }
186
+ return trimmed.split(":")[0] || null;
187
+ }
188
+ function isLoopbackHostname(hostname) {
189
+ if (!hostname) return false;
190
+ return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname);
191
+ }
121
192
  function maskSecrets(value, pattern = SECRET_KEY_PATTERN) {
193
+ if (typeof value === "string") {
194
+ return maskSecretText(value);
195
+ }
122
196
  if (value !== null && typeof value === "object") {
123
197
  if (Array.isArray(value)) {
124
198
  return value.map((v) => maskSecrets(v, pattern));
@@ -142,17 +216,133 @@ function maskSecrets(value, pattern = SECRET_KEY_PATTERN) {
142
216
  return value;
143
217
  }
144
218
 
145
- // src/confirmations.ts
219
+ // src/guard.ts
146
220
  var import_node_crypto = require("crypto");
221
+ var AUTH_COOKIE = "nodeui_token";
222
+ var FORWARD_HEADERS = ["x-forwarded-for", "x-forwarded-host", "forwarded", "x-real-ip"];
223
+ function digest(value) {
224
+ return (0, import_node_crypto.createHash)("sha256").update(value).digest();
225
+ }
226
+ function tokenEquals(a, b) {
227
+ return (0, import_node_crypto.timingSafeEqual)(digest(a), digest(b));
228
+ }
229
+ function readCookie(header, name) {
230
+ if (!header) return void 0;
231
+ for (const part of header.split(";")) {
232
+ const [k, ...rest] = part.trim().split("=");
233
+ if (k === name) {
234
+ try {
235
+ return decodeURIComponent(rest.join("="));
236
+ } catch {
237
+ return void 0;
238
+ }
239
+ }
240
+ }
241
+ return void 0;
242
+ }
243
+ function firstHeader(value) {
244
+ return Array.isArray(value) ? value[0] : value;
245
+ }
246
+ function deny(status, code, message) {
247
+ return { ok: false, status, code, message };
248
+ }
249
+ function createGuard(options) {
250
+ const strict = isLoopbackAddress(options.host);
251
+ const allowedHosts = options.allowedHosts.map((h) => h.toLowerCase());
252
+ const allowedOrigins = options.allowedOrigins.map((o) => o.toLowerCase().replace(/\/+$/, ""));
253
+ return (req) => {
254
+ const remote = req.socket.remoteAddress;
255
+ const remoteLoopback = isLoopbackAddress(remote);
256
+ const remoteAllowed = remoteLoopback || matchesAddress(remote, options.allowedRemoteAddresses);
257
+ if (strict && !remoteAllowed) {
258
+ return deny(
259
+ 403,
260
+ "forbidden",
261
+ "nodeui is loopback-only; requests from other hosts are rejected"
262
+ );
263
+ }
264
+ if (!strict && options.allowedRemoteAddresses.length > 0 && !remoteAllowed) {
265
+ return deny(403, "forbidden", "remote address is not in the nodeui allow-list");
266
+ }
267
+ if (strict && !options.trustProxy && !matchesAddress(remote, options.allowedRemoteAddresses)) {
268
+ if (FORWARD_HEADERS.some((h) => req.headers[h] !== void 0)) {
269
+ return deny(
270
+ 403,
271
+ "forbidden",
272
+ "proxied requests are rejected (set trustProxy to allow forwarded traffic)"
273
+ );
274
+ }
275
+ }
276
+ const hostHeader = firstHeader(req.headers.host);
277
+ const hostname = hostnameFromHostHeader(hostHeader);
278
+ if (hostname !== null) {
279
+ const hostOk = isLoopbackHostname(hostname) || allowedHosts.includes(hostname) || !strict && allowedHosts.length === 0;
280
+ if (!hostOk) {
281
+ return deny(
282
+ 403,
283
+ "forbidden",
284
+ `Host "${hostname}" is not allowed (add it to allowedHosts to use nodeui via this name)`
285
+ );
286
+ }
287
+ }
288
+ const origin = firstHeader(req.headers.origin);
289
+ if (origin !== void 0) {
290
+ let originOk = false;
291
+ const normalized = origin.toLowerCase().replace(/\/+$/, "");
292
+ if (allowedOrigins.includes(normalized)) {
293
+ originOk = true;
294
+ } else {
295
+ try {
296
+ originOk = hostHeader !== void 0 && new URL(origin).host === hostHeader.toLowerCase();
297
+ } catch {
298
+ originOk = false;
299
+ }
300
+ }
301
+ if (!originOk) {
302
+ return deny(403, "forbidden", "cross-origin requests to nodeui are rejected");
303
+ }
304
+ }
305
+ if (options.authToken) {
306
+ const authHeader = firstHeader(req.headers.authorization);
307
+ const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : void 0;
308
+ const queryToken = new URL(req.url ?? "/", "http://localhost").searchParams.get("token");
309
+ const cookieToken = readCookie(firstHeader(req.headers.cookie), AUTH_COOKIE);
310
+ const candidates = [
311
+ bearer,
312
+ firstHeader(req.headers["x-nodeui-token"]),
313
+ cookieToken,
314
+ queryToken ?? void 0
315
+ ];
316
+ const matched = candidates.find((c) => c !== void 0 && tokenEquals(c, options.authToken));
317
+ if (matched === void 0) {
318
+ return deny(401, "unauthorized", "a valid nodeui token is required");
319
+ }
320
+ if (queryToken !== null && matched === queryToken) {
321
+ return { ok: true, setToken: queryToken };
322
+ }
323
+ }
324
+ return { ok: true };
325
+ };
326
+ }
327
+
328
+ // src/confirmations.ts
329
+ var import_node_crypto2 = require("crypto");
147
330
  var ConfirmationStore = class {
148
- constructor(ttlMs) {
331
+ constructor(ttlMs, maxOutstanding = 100) {
149
332
  this.ttlMs = ttlMs;
333
+ this.maxOutstanding = maxOutstanding;
150
334
  }
151
335
  ttlMs;
336
+ maxOutstanding;
152
337
  nonces = /* @__PURE__ */ new Map();
153
338
  issue() {
154
339
  this.prune();
155
- const nonce = (0, import_node_crypto.randomBytes)(16).toString("hex");
340
+ while (this.nonces.size >= this.maxOutstanding) {
341
+ const oldest = this.nonces.keys().next().value;
342
+ if (oldest === void 0) break;
343
+ this.nonces.delete(oldest);
344
+ }
345
+ const nonce = (0, import_node_crypto2.randomBytes)(16).toString("hex");
156
346
  const expiresAtMs = Date.now() + this.ttlMs;
157
347
  this.nonces.set(nonce, expiresAtMs);
158
348
  return { nonce, expiresAtMs, ttlMs: this.ttlMs };
@@ -464,15 +654,65 @@ var EventLoopLagProvider = class {
464
654
  // src/providers/health.ts
465
655
  var LAG_OK_MS = 50;
466
656
  var LAG_DEGRADED_MS = 200;
657
+ var CHECK_TIMEOUT_MS = 3e3;
658
+ var CHECK_CACHE_MS = 5e3;
659
+ function withTimeout(promise, ms) {
660
+ return new Promise((resolve3, reject) => {
661
+ const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
662
+ promise.then(
663
+ (value) => {
664
+ clearTimeout(timer);
665
+ resolve3(value);
666
+ },
667
+ (err) => {
668
+ clearTimeout(timer);
669
+ reject(err instanceof Error ? err : new Error(String(err)));
670
+ }
671
+ );
672
+ });
673
+ }
467
674
  var HealthProvider = class {
675
+ constructor(checks = {}) {
676
+ this.checks = checks;
677
+ }
678
+ checks;
468
679
  id = "health";
469
- get(ctx) {
680
+ cache = null;
681
+ async runChecks() {
682
+ const names = Object.keys(this.checks);
683
+ if (names.length === 0) return [];
684
+ if (this.cache && Date.now() - this.cache.at < CHECK_CACHE_MS) return this.cache.results;
685
+ const results = await Promise.all(
686
+ names.map(async (name) => {
687
+ const started = Date.now();
688
+ try {
689
+ const outcome = await withTimeout(
690
+ Promise.resolve().then(() => this.checks[name]()),
691
+ CHECK_TIMEOUT_MS
692
+ );
693
+ if (outcome === false) throw new Error("check returned false");
694
+ return { name, status: "up", durationMs: Date.now() - started };
695
+ } catch (err) {
696
+ return {
697
+ name,
698
+ status: "down",
699
+ durationMs: Date.now() - started,
700
+ error: err instanceof Error ? err.message : String(err)
701
+ };
702
+ }
703
+ })
704
+ );
705
+ this.cache = { at: Date.now(), results };
706
+ return results;
707
+ }
708
+ async get(ctx) {
470
709
  const loop = ctx.store["event-loop"];
471
710
  const memory = ctx.store.memory;
472
711
  const lagMs = typeof loop?.currentMs === "number" ? loop.currentMs : null;
473
712
  const totalMem = typeof memory?.totalMem === "number" ? memory.totalMem : 0;
474
713
  const rss = typeof memory?.rss === "number" ? memory.rss : 0;
475
714
  const memoryUsedPercent = totalMem > 0 ? rss / totalMem * 100 : null;
715
+ const checks = await this.runChecks();
476
716
  let status;
477
717
  let statusReason;
478
718
  if (lagMs === null) {
@@ -488,6 +728,11 @@ var HealthProvider = class {
488
728
  status = "critical";
489
729
  statusReason = `event-loop lag ${lagMs.toFixed(1)}ms above ${LAG_DEGRADED_MS}ms`;
490
730
  }
731
+ const down = checks.filter((c) => c.status === "down");
732
+ if (down.length > 0) {
733
+ status = "critical";
734
+ statusReason = `dependency down: ${down.map((c) => c.name).join(", ")}`;
735
+ }
491
736
  return {
492
737
  ok: true,
493
738
  data: {
@@ -498,19 +743,202 @@ var HealthProvider = class {
498
743
  nodeVersion: process.version,
499
744
  platform: process.platform,
500
745
  eventLoopLagMs: lagMs,
501
- memoryUsedPercent
746
+ memoryUsedPercent,
747
+ checks
502
748
  }
503
749
  };
504
750
  }
505
751
  };
506
752
 
753
+ // src/providers/outgoing.ts
754
+ var import_node_diagnostics_channel = __toESM(require("diagnostics_channel"));
755
+ var import_node_http = __toESM(require("http"));
756
+ var import_node_https = __toESM(require("https"));
757
+ var import_node_module = require("module");
758
+ var RESPONSE_PAGE_SIZE = 100;
759
+ var recorders = /* @__PURE__ */ new Set();
760
+ var originals = /* @__PURE__ */ new Map();
761
+ var undiciUnsubscribe = null;
762
+ function describeTarget(defaultProtocol, args) {
763
+ try {
764
+ let method = "GET";
765
+ let host = "unknown";
766
+ let path = "/";
767
+ const [first, second] = args;
768
+ const opts = typeof first === "string" || first instanceof URL ? second : first;
769
+ if (typeof first === "string" || first instanceof URL) {
770
+ const parsed = new URL(first.toString());
771
+ host = parsed.host;
772
+ path = parsed.pathname;
773
+ }
774
+ if (opts && typeof opts === "object") {
775
+ if (typeof opts.method === "string") method = opts.method.toUpperCase();
776
+ if (typeof first !== "string" && !(first instanceof URL)) {
777
+ const name = opts.hostname ?? opts.host ?? "localhost";
778
+ host = opts.port ? `${name}:${String(opts.port)}` : name;
779
+ path = typeof opts.path === "string" ? opts.path.split("?")[0] ?? "/" : "/";
780
+ const protocol = typeof opts.protocol === "string" ? opts.protocol : defaultProtocol;
781
+ return { method, url: `${protocol}//${host}${path}` };
782
+ }
783
+ }
784
+ return { method, url: `${defaultProtocol}//${host}${path}` };
785
+ } catch {
786
+ return null;
787
+ }
788
+ }
789
+ function emit(entry) {
790
+ for (const record of recorders) {
791
+ try {
792
+ record(entry);
793
+ } catch {
794
+ }
795
+ }
796
+ }
797
+ function wrapModule(mod, protocol) {
798
+ for (const name of ["request", "get"]) {
799
+ const key = `${protocol}${name}`;
800
+ const original = mod[name];
801
+ originals.set(key, original);
802
+ const wrapped = function(...args) {
803
+ const req = original.apply(this, args);
804
+ const target = recorders.size > 0 ? describeTarget(protocol, args) : null;
805
+ if (target && req && typeof req.once === "function") {
806
+ const started = process.hrtime.bigint();
807
+ const timestampMs = Date.now();
808
+ let done = false;
809
+ const finish = (status, error) => {
810
+ if (done) return;
811
+ done = true;
812
+ emit({
813
+ ...target,
814
+ status,
815
+ durationMs: Number(process.hrtime.bigint() - started) / 1e6,
816
+ timestampMs,
817
+ ...error ? { error } : {}
818
+ });
819
+ };
820
+ req.once("response", (res) => {
821
+ const status = res.statusCode ?? null;
822
+ res.once("end", () => finish(status));
823
+ res.once("close", () => finish(status));
824
+ });
825
+ req.once("error", (err) => finish(null, err.message));
826
+ }
827
+ return req;
828
+ };
829
+ mod[name] = wrapped;
830
+ }
831
+ (0, import_node_module.syncBuiltinESMExports)();
832
+ }
833
+ function unwrapModule(mod, protocol) {
834
+ for (const name of ["request", "get"]) {
835
+ const original = originals.get(`${protocol}${name}`);
836
+ if (original) mod[name] = original;
837
+ originals.delete(`${protocol}${name}`);
838
+ }
839
+ (0, import_node_module.syncBuiltinESMExports)();
840
+ }
841
+ function subscribeUndici() {
842
+ const started = /* @__PURE__ */ new WeakMap();
843
+ const statuses = /* @__PURE__ */ new WeakMap();
844
+ const target = (req) => ({
845
+ method: (req.method ?? "GET").toUpperCase(),
846
+ url: `${String(req.origin ?? "")}${(req.path ?? "/").split("?")[0]}`
847
+ });
848
+ const onCreate = (message) => {
849
+ const request = message.request;
850
+ if (request) started.set(request, { at: process.hrtime.bigint(), ts: Date.now() });
851
+ };
852
+ const onHeaders = (message) => {
853
+ const { request, response } = message;
854
+ if (request && typeof response?.statusCode === "number") {
855
+ statuses.set(request, response.statusCode);
856
+ }
857
+ };
858
+ const finish = (request, error) => {
859
+ if (!request) return;
860
+ const begin = started.get(request);
861
+ if (!begin) return;
862
+ started.delete(request);
863
+ emit({
864
+ ...target(request),
865
+ status: statuses.get(request) ?? null,
866
+ durationMs: Number(process.hrtime.bigint() - begin.at) / 1e6,
867
+ timestampMs: begin.ts,
868
+ ...error ? { error } : {}
869
+ });
870
+ };
871
+ const onTrailers = (message) => finish(message.request);
872
+ const onError = (message) => {
873
+ const { request, error } = message;
874
+ finish(request, error?.message ?? "request failed");
875
+ };
876
+ import_node_diagnostics_channel.default.subscribe("undici:request:create", onCreate);
877
+ import_node_diagnostics_channel.default.subscribe("undici:request:headers", onHeaders);
878
+ import_node_diagnostics_channel.default.subscribe("undici:request:trailers", onTrailers);
879
+ import_node_diagnostics_channel.default.subscribe("undici:request:error", onError);
880
+ return () => {
881
+ import_node_diagnostics_channel.default.unsubscribe("undici:request:create", onCreate);
882
+ import_node_diagnostics_channel.default.unsubscribe("undici:request:headers", onHeaders);
883
+ import_node_diagnostics_channel.default.unsubscribe("undici:request:trailers", onTrailers);
884
+ import_node_diagnostics_channel.default.unsubscribe("undici:request:error", onError);
885
+ };
886
+ }
887
+ function interceptOutgoing(record) {
888
+ const first = recorders.size === 0;
889
+ recorders.add(record);
890
+ if (first) {
891
+ wrapModule(import_node_http.default, "http:");
892
+ wrapModule(import_node_https.default, "https:");
893
+ try {
894
+ undiciUnsubscribe = subscribeUndici();
895
+ } catch {
896
+ undiciUnsubscribe = null;
897
+ }
898
+ }
899
+ return () => {
900
+ if (!recorders.delete(record) || recorders.size > 0) return;
901
+ unwrapModule(import_node_http.default, "http:");
902
+ unwrapModule(import_node_https.default, "https:");
903
+ undiciUnsubscribe?.();
904
+ undiciUnsubscribe = null;
905
+ };
906
+ }
907
+ var OutgoingProvider = class {
908
+ id = "outgoing";
909
+ buffer;
910
+ nextId = 1;
911
+ failed = 0;
912
+ release = null;
913
+ constructor(size) {
914
+ this.buffer = new RingBuffer(size);
915
+ }
916
+ start() {
917
+ if (this.release) return;
918
+ this.release = interceptOutgoing((entry) => this.record(entry));
919
+ }
920
+ stop() {
921
+ this.release?.();
922
+ this.release = null;
923
+ }
924
+ record(entry) {
925
+ this.buffer.push({ id: this.nextId, ...entry });
926
+ this.nextId += 1;
927
+ if (entry.error || entry.status !== null && entry.status >= 500) this.failed += 1;
928
+ }
929
+ get() {
930
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE));
931
+ return { ok: true, data: { total: this.nextId - 1, failed: this.failed, entries } };
932
+ }
933
+ };
934
+
507
935
  // src/providers/heap-snapshot.ts
508
936
  var import_promises = require("fs/promises");
509
937
  var import_node_os2 = require("os");
510
938
  var import_node_path2 = require("path");
511
939
  var import_node_v8 = require("v8");
512
940
  function defaultSnapshotDir() {
513
- return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_os2.tmpdir)();
941
+ return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "nodeui-heap");
514
942
  }
515
943
  var HeapSnapshotProvider = class {
516
944
  id = "heap-snapshot";
@@ -524,8 +952,9 @@ var HeapSnapshotProvider = class {
524
952
  const fileName = `nodeui-heap-${process.pid}-${stamp}.heapsnapshot`;
525
953
  const filePath = (0, import_node_path2.join)(dir, fileName);
526
954
  try {
527
- await (0, import_promises.mkdir)(dir, { recursive: true });
955
+ await (0, import_promises.mkdir)(dir, { recursive: true, mode: 448 });
528
956
  (0, import_node_v8.writeHeapSnapshot)(filePath);
957
+ await (0, import_promises.chmod)(filePath, 384).catch(() => void 0);
529
958
  const fileStat = await (0, import_promises.stat)(filePath);
530
959
  const data = {
531
960
  fileName,
@@ -548,7 +977,7 @@ var HeapSnapshotProvider = class {
548
977
  };
549
978
 
550
979
  // src/providers/requests.ts
551
- var RESPONSE_PAGE_SIZE = 100;
980
+ var RESPONSE_PAGE_SIZE2 = 100;
552
981
  var RequestsProvider = class {
553
982
  id = "requests";
554
983
  buffer;
@@ -564,7 +993,7 @@ var RequestsProvider = class {
564
993
  return this.buffer.length;
565
994
  }
566
995
  get() {
567
- const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE));
996
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE2));
568
997
  return { ok: true, data: { total: this.buffer.length, entries } };
569
998
  }
570
999
  };
@@ -679,6 +1108,11 @@ var RoutesProvider = class {
679
1108
  id = "routes";
680
1109
  cache = /* @__PURE__ */ new WeakMap();
681
1110
  get(ctx) {
1111
+ const source = ctx.store["routes-source"];
1112
+ if (source !== void 0) {
1113
+ const routes2 = typeof source === "function" ? source() : source;
1114
+ return { ok: true, data: { routes: routes2 } };
1115
+ }
682
1116
  const router = ctx.store["express-router"];
683
1117
  if (!router) {
684
1118
  return {
@@ -727,14 +1161,14 @@ function formatArg(arg) {
727
1161
  var refs = 0;
728
1162
  var pushes = /* @__PURE__ */ new Set();
729
1163
  var wrappers = /* @__PURE__ */ new Map();
730
- var originals = {};
1164
+ var originals2 = {};
731
1165
  function consoleRef() {
732
1166
  return console;
733
1167
  }
734
1168
  function installWrappers() {
735
1169
  for (const [method, level] of CONSOLE_METHODS) {
736
1170
  const original = consoleRef()[method];
737
- originals[method] = original;
1171
+ originals2[method] = original;
738
1172
  const wrapper = (...args) => {
739
1173
  for (const push of pushes) {
740
1174
  try {
@@ -752,7 +1186,7 @@ function uninstallWrappers() {
752
1186
  for (const [method] of CONSOLE_METHODS) {
753
1187
  const current = consoleRef()[method];
754
1188
  if (current === wrappers.get(method)) {
755
- const original = originals[method];
1189
+ const original = originals2[method];
756
1190
  if (original) {
757
1191
  consoleRef()[method] = original;
758
1192
  } else {
@@ -761,7 +1195,7 @@ function uninstallWrappers() {
761
1195
  }
762
1196
  }
763
1197
  wrappers.clear();
764
- originals = {};
1198
+ originals2 = {};
765
1199
  }
766
1200
  function interceptConsole(push) {
767
1201
  refs += 1;
@@ -808,9 +1242,10 @@ var LogsProvider = class {
808
1242
  };
809
1243
 
810
1244
  // src/sse.ts
811
- function startSse(res) {
1245
+ function startSse(res, extraHeaders = {}) {
812
1246
  let closed = false;
813
1247
  res.writeHead(200, {
1248
+ ...extraHeaders,
814
1249
  "Content-Type": "text/event-stream",
815
1250
  "Cache-Control": "no-cache",
816
1251
  Connection: "keep-alive",
@@ -841,8 +1276,35 @@ var import_node_os3 = require("os");
841
1276
  var import_node_path3 = require("path");
842
1277
  var STATIC_ROOT = (0, import_node_path3.resolve)(__dirname, "..", "static");
843
1278
  var SSE_HEARTBEAT_MS = 15e3;
844
- function serializeEnvelope(envelope) {
845
- return JSON.stringify(maskSecrets(envelope));
1279
+ function serializeEnvelope(envelope, mask = true) {
1280
+ return JSON.stringify(mask ? maskSecrets(envelope) : envelope);
1281
+ }
1282
+ var BUILT_IN_PANELS = /* @__PURE__ */ new Set([
1283
+ "health",
1284
+ "memory",
1285
+ "cpu",
1286
+ "event-loop",
1287
+ "heap-snapshot",
1288
+ "startup",
1289
+ "requests",
1290
+ "env",
1291
+ "routes",
1292
+ "logs",
1293
+ "metrics",
1294
+ "outgoing"
1295
+ ]);
1296
+ var PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1297
+ var RESERVED_API_NAMES = /* @__PURE__ */ new Set(["config", "live", "confirmations"]);
1298
+ var SECURITY_HEADERS = {
1299
+ "X-Content-Type-Options": "nosniff",
1300
+ "X-Frame-Options": "DENY",
1301
+ "Referrer-Policy": "no-referrer",
1302
+ "Cross-Origin-Resource-Policy": "same-origin"
1303
+ };
1304
+ 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'";
1305
+ function listFrom(value, raw) {
1306
+ if (value) return value;
1307
+ return (raw ?? "").split(",").map((v) => v.trim()).filter(Boolean);
846
1308
  }
847
1309
  function pathnameOf(req) {
848
1310
  const raw = req.originalUrl ?? req.url ?? "/";
@@ -902,19 +1364,46 @@ function createNodeUI(options = {}) {
902
1364
  env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
903
1365
  "NODEUI_CONFIRM_TTL_MS"
904
1366
  ),
905
- heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_os3.tmpdir)()
1367
+ heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_path3.join)((0, import_node_os3.tmpdir)(), "nodeui-heap"),
1368
+ allowedHosts: listFrom(options.allowedHosts, env.NODEUI_ALLOWED_HOSTS),
1369
+ allowedOrigins: listFrom(options.allowedOrigins, env.NODEUI_ALLOWED_ORIGINS),
1370
+ allowedRemoteAddresses: listFrom(options.allowedRemoteAddresses, env.NODEUI_ALLOWED_REMOTE),
1371
+ trustProxy: options.trustProxy ?? env.NODEUI_TRUST_PROXY === "true",
1372
+ authRequired: Boolean(options.authToken ?? env.NODEUI_TOKEN),
1373
+ maxSseClients: positiveInt(
1374
+ options.maxSseClients,
1375
+ env.NODEUI_MAX_SSE_CLIENTS ? Number(env.NODEUI_MAX_SSE_CLIENTS) : 10,
1376
+ "NODEUI_MAX_SSE_CLIENTS"
1377
+ )
906
1378
  };
907
- if (!isLoopbackAddress(config.host)) {
1379
+ const authToken = options.authToken ?? env.NODEUI_TOKEN;
1380
+ const guard = createGuard({
1381
+ host: config.host,
1382
+ allowedHosts: config.allowedHosts,
1383
+ allowedOrigins: config.allowedOrigins,
1384
+ allowedRemoteAddresses: config.allowedRemoteAddresses,
1385
+ trustProxy: config.trustProxy,
1386
+ authToken: authToken || void 0
1387
+ });
1388
+ if (config.enabled && !isLoopbackAddress(config.host)) {
908
1389
  console.warn(
909
- `[nodeui] NODEUI_HOST is set to non-loopback "${config.host}". The developer console will be reachable from other hosts; only do this deliberately.`
1390
+ `[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.")
910
1391
  );
911
1392
  }
1393
+ if (config.enabled && env.NODE_ENV === "production") {
1394
+ console.warn(
1395
+ "[nodeui] running with NODE_ENV=production. The console exposes environment, logs and heap data; keep it behind loopback and an auth token."
1396
+ );
1397
+ }
1398
+ if (config.enabled && !config.maskSecrets) {
1399
+ console.warn("[nodeui] secret masking is disabled; panels may show credentials.");
1400
+ }
912
1401
  const ctx = { config, env, store: {} };
913
1402
  if (options.config !== void 0) {
914
1403
  ctx.store["app-config"] = options.config;
915
1404
  }
916
1405
  const registry = new ProviderRegistry();
917
- registry.register(new HealthProvider());
1406
+ registry.register(new HealthProvider(options.healthChecks));
918
1407
  registry.register(new MemoryProvider());
919
1408
  registry.register(new CpuProvider());
920
1409
  registry.register(new EventLoopLagProvider());
@@ -936,9 +1425,23 @@ function createNodeUI(options = {}) {
936
1425
  registry.register(envProvider);
937
1426
  const routesProvider = new RoutesProvider();
938
1427
  registry.register(routesProvider);
1428
+ registry.register(
1429
+ new OutgoingProvider(positiveInt(options.outgoingLogSize, 200, "outgoingLogSize"))
1430
+ );
1431
+ const pluginMeta = [];
1432
+ for (const plugin of options.plugins ?? []) {
1433
+ if (!PLUGIN_ID_PATTERN.test(plugin.id) || BUILT_IN_PANELS.has(plugin.id) || RESERVED_API_NAMES.has(plugin.id) || registry.get(plugin.id)) {
1434
+ throw new Error(
1435
+ `nodeui plugin id "${plugin.id}" is invalid or already taken (use lowercase letters, digits and "-")`
1436
+ );
1437
+ }
1438
+ registry.register(plugin);
1439
+ pluginMeta.push({ id: plugin.id, title: plugin.title ?? plugin.id });
1440
+ }
939
1441
  const confirmations = new ConfirmationStore(config.confirmTtlMs);
940
1442
  const activeProviders = /* @__PURE__ */ new Set();
941
1443
  let inactivityTimer = null;
1444
+ let sseClients = 0;
942
1445
  function stopAll() {
943
1446
  for (const id of activeProviders) {
944
1447
  registry.get(id)?.stop?.(ctx);
@@ -962,8 +1465,9 @@ function createNodeUI(options = {}) {
962
1465
  resetInactivityTimer();
963
1466
  }
964
1467
  function sendJson(res, status, envelope) {
965
- const body = serializeEnvelope(envelope);
1468
+ const body = serializeEnvelope(envelope, config.maskSecrets);
966
1469
  res.writeHead(status, {
1470
+ ...SECURITY_HEADERS,
967
1471
  "Content-Type": "application/json",
968
1472
  "Content-Length": Buffer.byteLength(body),
969
1473
  "Cache-Control": "no-store"
@@ -981,8 +1485,24 @@ function createNodeUI(options = {}) {
981
1485
  const provider = registry.get(id);
982
1486
  if (!provider) return notFound(res);
983
1487
  const requestCtx = query ? { ...ctx, query } : ctx;
984
- const result = await provider.get(requestCtx);
985
- sendJson(res, result.ok ? 200 : 500, result);
1488
+ sendJson(res, ...await readPanel(provider, requestCtx));
1489
+ }
1490
+ async function readPanel(provider, requestCtx) {
1491
+ try {
1492
+ const result = await provider.get(requestCtx);
1493
+ return [result.ok ? 200 : 500, result];
1494
+ } catch (err) {
1495
+ return [
1496
+ 500,
1497
+ {
1498
+ ok: false,
1499
+ error: {
1500
+ code: "provider-failed",
1501
+ message: err instanceof Error ? err.message : "provider failed"
1502
+ }
1503
+ }
1504
+ ];
1505
+ }
986
1506
  }
987
1507
  function configData() {
988
1508
  return {
@@ -995,7 +1515,9 @@ function createNodeUI(options = {}) {
995
1515
  logSize: config.logSize,
996
1516
  pollIntervalMs: config.pollIntervalMs,
997
1517
  panels: registry.ids(),
998
- masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source }
1518
+ masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source },
1519
+ authRequired: config.authRequired,
1520
+ plugins: pluginMeta
999
1521
  };
1000
1522
  }
1001
1523
  function issueConfirmation(res) {
@@ -1027,22 +1549,37 @@ function createNodeUI(options = {}) {
1027
1549
  const requested = (search.get("panels") ?? "").split(",").filter(Boolean);
1028
1550
  const panels = (requested.length > 0 ? requested.filter((id) => registry.get(id) !== void 0) : registry.ids()).filter((value, index, self) => self.indexOf(value) === index);
1029
1551
  if (panels.length === 0) return notFound(res);
1030
- const stream = startSse(res);
1552
+ if (sseClients >= config.maxSseClients) {
1553
+ res.writeHead(429, { ...SECURITY_HEADERS, "Content-Type": "application/json" });
1554
+ res.end(
1555
+ JSON.stringify({
1556
+ ok: false,
1557
+ error: { code: "too-many-streams", message: "too many open live streams" }
1558
+ })
1559
+ );
1560
+ return;
1561
+ }
1562
+ sseClients += 1;
1563
+ const stream = startSse(res, SECURITY_HEADERS);
1031
1564
  const push = async () => {
1032
1565
  for (const id of panels) {
1033
1566
  ensureActive(id);
1034
1567
  const provider = registry.get(id);
1035
1568
  if (!provider) continue;
1036
- const result = await provider.get(ctx);
1037
- stream.send({ panel: id, envelope: result });
1569
+ const [, result] = await readPanel(provider, ctx);
1570
+ stream.send({ panel: id, envelope: config.maskSecrets ? maskSecrets(result) : result });
1038
1571
  }
1039
1572
  };
1040
- void push();
1041
- const pushTimer = setInterval(() => void push(), config.pollIntervalMs);
1573
+ void push().catch(() => void 0);
1574
+ const pushTimer = setInterval(() => void push().catch(() => void 0), config.pollIntervalMs);
1042
1575
  const heartbeatTimer = setInterval(() => stream.heartbeat(), SSE_HEARTBEAT_MS);
1043
1576
  if (typeof pushTimer.unref === "function") pushTimer.unref();
1044
1577
  if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
1578
+ let cleaned = false;
1045
1579
  const cleanup = () => {
1580
+ if (cleaned) return;
1581
+ cleaned = true;
1582
+ sseClients -= 1;
1046
1583
  clearInterval(pushTimer);
1047
1584
  clearInterval(heartbeatTimer);
1048
1585
  stream.close();
@@ -1085,30 +1622,33 @@ function createNodeUI(options = {}) {
1085
1622
  return;
1086
1623
  }
1087
1624
  res.writeHead(200, {
1625
+ ...SECURITY_HEADERS,
1626
+ "Content-Security-Policy": CSP,
1088
1627
  "Content-Type": asset.contentType,
1089
1628
  "Content-Length": asset.length,
1090
1629
  "Cache-Control": "no-cache"
1091
1630
  });
1092
1631
  asset.content.pipe(res);
1093
1632
  }
1094
- function guardLoopback(req, res) {
1095
- if (isLoopbackAddress(config.host)) {
1096
- const remote = req.socket.remoteAddress;
1097
- if (!isLoopbackAddress(remote)) {
1098
- sendJson(res, 403, {
1099
- ok: false,
1100
- error: {
1101
- code: "forbidden",
1102
- message: "nodeui is loopback-only; requests from other hosts are rejected"
1103
- }
1104
- });
1105
- return false;
1106
- }
1107
- }
1108
- return true;
1109
- }
1110
1633
  async function handleNodeUIPath(req, res, urlPath) {
1111
- if (!guardLoopback(req, res)) return;
1634
+ const verdict = guard(req);
1635
+ if (!verdict.ok) {
1636
+ sendJson(res, verdict.status, {
1637
+ ok: false,
1638
+ error: { code: verdict.code, message: verdict.message }
1639
+ });
1640
+ return;
1641
+ }
1642
+ if (verdict.setToken !== void 0) {
1643
+ res.writeHead(302, {
1644
+ ...SECURITY_HEADERS,
1645
+ "Set-Cookie": `${AUTH_COOKIE}=${encodeURIComponent(verdict.setToken)}; Path=${path}; HttpOnly; SameSite=Strict`,
1646
+ Location: `${path}/`,
1647
+ "Cache-Control": "no-store"
1648
+ });
1649
+ res.end();
1650
+ return;
1651
+ }
1112
1652
  const apiBase = `${path}/api`;
1113
1653
  if (urlPath === apiBase || urlPath.startsWith(`${apiBase}/`)) {
1114
1654
  await handleApi(req, res, urlPath.slice(apiBase.length));
@@ -1192,6 +1732,9 @@ function createNodeUI(options = {}) {
1192
1732
  }
1193
1733
  notFound(res);
1194
1734
  },
1735
+ setRoutes(source) {
1736
+ ctx.store["routes-source"] = source;
1737
+ },
1195
1738
  mark(name) {
1196
1739
  startupTracker.mark(name);
1197
1740
  },
@@ -1209,6 +1752,7 @@ function createNodeUI(options = {}) {
1209
1752
  }
1210
1753
  // Annotate the CommonJS export names for ESM import in node:
1211
1754
  0 && (module.exports = {
1755
+ AUTH_COOKIE,
1212
1756
  ConfirmationStore,
1213
1757
  CpuProvider,
1214
1758
  DEFAULT_LOG_SIZE,
@@ -1219,6 +1763,7 @@ function createNodeUI(options = {}) {
1219
1763
  LogsProvider,
1220
1764
  MemoryProvider,
1221
1765
  MetricsProvider,
1766
+ OutgoingProvider,
1222
1767
  ProviderRegistry,
1223
1768
  RequestsProvider,
1224
1769
  RingBuffer,
@@ -1227,11 +1772,17 @@ function createNodeUI(options = {}) {
1227
1772
  SECRET_MASKED,
1228
1773
  Sampler,
1229
1774
  StartupTracker,
1775
+ createGuard,
1230
1776
  createNodeUI,
1231
1777
  extractRoutes,
1778
+ hostnameFromHostHeader,
1232
1779
  interceptConsole,
1780
+ interceptOutgoing,
1233
1781
  isLoopbackAddress,
1782
+ isLoopbackHostname,
1783
+ maskSecretText,
1234
1784
  maskSecrets,
1785
+ matchesAddress,
1235
1786
  resolveActivation,
1236
1787
  resolveStaticAsset,
1237
1788
  serializeEnvelope,