@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.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,15 +50,22 @@ __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,
49
- startSse: () => startSse
67
+ startSse: () => startSse,
68
+ summarizeRequests: () => summarizeRequests
50
69
  });
51
70
  module.exports = __toCommonJS(index_exports);
52
71
 
@@ -95,7 +114,15 @@ var RingBuffer = class {
95
114
  };
96
115
 
97
116
  // src/safety.ts
98
- var SECRET_KEY_PATTERN = /token|key|secret|password|credential/i;
117
+ 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;
118
+ var TEXT_PAIR_PATTERN = /\b([\w.-]*(?:token|secret|passw(?:or)?d|api[_-]?key|credential|authorization|cookie|dsn)[\w.-]*["']?)(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s,;&"'}]+)/gi;
119
+ var URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+(@)/gi;
120
+ var BEARER_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*/gi;
121
+ var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g;
122
+ function maskSecretText(text) {
123
+ if (text.length < 6) return text;
124
+ 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}`);
125
+ }
99
126
  function resolveActivation(env) {
100
127
  if (env.NODEUI_ENABLED === "true") {
101
128
  return { active: true, reason: "activated by NODEUI_ENABLED=true" };
@@ -118,7 +145,55 @@ function isLoopbackAddress(address) {
118
145
  if (!address) return false;
119
146
  return address.startsWith("127.") || address === "::1" || address.startsWith("::ffff:127.") || address === "localhost";
120
147
  }
148
+ function normalizeIp(address) {
149
+ return address.startsWith("::ffff:") ? address.slice(7) : address;
150
+ }
151
+ function ipv4ToInt(ip) {
152
+ const parts = ip.split(".");
153
+ if (parts.length !== 4) return null;
154
+ let out = 0;
155
+ for (const part of parts) {
156
+ const n = Number(part);
157
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null;
158
+ out = out * 256 + n;
159
+ }
160
+ return out;
161
+ }
162
+ function matchesAddress(address, allowed) {
163
+ if (!address) return false;
164
+ const ip = normalizeIp(address);
165
+ for (const entry of allowed) {
166
+ if (!entry.includes("/")) {
167
+ if (normalizeIp(entry) === ip) return true;
168
+ continue;
169
+ }
170
+ const [base, bitsRaw] = entry.split("/");
171
+ const bits = Number(bitsRaw);
172
+ const a = ipv4ToInt(ip);
173
+ const b = ipv4ToInt(base ?? "");
174
+ if (a === null || b === null || !Number.isInteger(bits) || bits < 0 || bits > 32) continue;
175
+ const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
176
+ if ((a & mask) >>> 0 === (b & mask) >>> 0) return true;
177
+ }
178
+ return false;
179
+ }
180
+ function hostnameFromHostHeader(value) {
181
+ if (!value) return null;
182
+ const trimmed = value.trim().toLowerCase();
183
+ if (trimmed.startsWith("[")) {
184
+ const end = trimmed.indexOf("]");
185
+ return end === -1 ? null : trimmed.slice(1, end);
186
+ }
187
+ return trimmed.split(":")[0] || null;
188
+ }
189
+ function isLoopbackHostname(hostname) {
190
+ if (!hostname) return false;
191
+ return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname);
192
+ }
121
193
  function maskSecrets(value, pattern = SECRET_KEY_PATTERN) {
194
+ if (typeof value === "string") {
195
+ return maskSecretText(value);
196
+ }
122
197
  if (value !== null && typeof value === "object") {
123
198
  if (Array.isArray(value)) {
124
199
  return value.map((v) => maskSecrets(v, pattern));
@@ -142,17 +217,133 @@ function maskSecrets(value, pattern = SECRET_KEY_PATTERN) {
142
217
  return value;
143
218
  }
144
219
 
145
- // src/confirmations.ts
220
+ // src/guard.ts
146
221
  var import_node_crypto = require("crypto");
222
+ var AUTH_COOKIE = "nodeui_token";
223
+ var FORWARD_HEADERS = ["x-forwarded-for", "x-forwarded-host", "forwarded", "x-real-ip"];
224
+ function digest(value) {
225
+ return (0, import_node_crypto.createHash)("sha256").update(value).digest();
226
+ }
227
+ function tokenEquals(a, b) {
228
+ return (0, import_node_crypto.timingSafeEqual)(digest(a), digest(b));
229
+ }
230
+ function readCookie(header, name) {
231
+ if (!header) return void 0;
232
+ for (const part of header.split(";")) {
233
+ const [k, ...rest] = part.trim().split("=");
234
+ if (k === name) {
235
+ try {
236
+ return decodeURIComponent(rest.join("="));
237
+ } catch {
238
+ return void 0;
239
+ }
240
+ }
241
+ }
242
+ return void 0;
243
+ }
244
+ function firstHeader(value) {
245
+ return Array.isArray(value) ? value[0] : value;
246
+ }
247
+ function deny(status, code, message) {
248
+ return { ok: false, status, code, message };
249
+ }
250
+ function createGuard(options) {
251
+ const strict = isLoopbackAddress(options.host);
252
+ const allowedHosts = options.allowedHosts.map((h) => h.toLowerCase());
253
+ const allowedOrigins = options.allowedOrigins.map((o) => o.toLowerCase().replace(/\/+$/, ""));
254
+ return (req) => {
255
+ const remote = req.socket.remoteAddress;
256
+ const remoteLoopback = isLoopbackAddress(remote);
257
+ const remoteAllowed = remoteLoopback || matchesAddress(remote, options.allowedRemoteAddresses);
258
+ if (strict && !remoteAllowed) {
259
+ return deny(
260
+ 403,
261
+ "forbidden",
262
+ "nodeui is loopback-only; requests from other hosts are rejected"
263
+ );
264
+ }
265
+ if (!strict && options.allowedRemoteAddresses.length > 0 && !remoteAllowed) {
266
+ return deny(403, "forbidden", "remote address is not in the nodeui allow-list");
267
+ }
268
+ if (strict && !options.trustProxy && !matchesAddress(remote, options.allowedRemoteAddresses)) {
269
+ if (FORWARD_HEADERS.some((h) => req.headers[h] !== void 0)) {
270
+ return deny(
271
+ 403,
272
+ "forbidden",
273
+ "proxied requests are rejected (set trustProxy to allow forwarded traffic)"
274
+ );
275
+ }
276
+ }
277
+ const hostHeader = firstHeader(req.headers.host);
278
+ const hostname = hostnameFromHostHeader(hostHeader);
279
+ if (hostname !== null) {
280
+ const hostOk = isLoopbackHostname(hostname) || allowedHosts.includes(hostname) || !strict && allowedHosts.length === 0;
281
+ if (!hostOk) {
282
+ return deny(
283
+ 403,
284
+ "forbidden",
285
+ `Host "${hostname}" is not allowed (add it to allowedHosts to use nodeui via this name)`
286
+ );
287
+ }
288
+ }
289
+ const origin = firstHeader(req.headers.origin);
290
+ if (origin !== void 0) {
291
+ let originOk = false;
292
+ const normalized = origin.toLowerCase().replace(/\/+$/, "");
293
+ if (allowedOrigins.includes(normalized)) {
294
+ originOk = true;
295
+ } else {
296
+ try {
297
+ originOk = hostHeader !== void 0 && new URL(origin).host === hostHeader.toLowerCase();
298
+ } catch {
299
+ originOk = false;
300
+ }
301
+ }
302
+ if (!originOk) {
303
+ return deny(403, "forbidden", "cross-origin requests to nodeui are rejected");
304
+ }
305
+ }
306
+ if (options.authToken) {
307
+ const authHeader = firstHeader(req.headers.authorization);
308
+ const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : void 0;
309
+ const queryToken = new URL(req.url ?? "/", "http://localhost").searchParams.get("token");
310
+ const cookieToken = readCookie(firstHeader(req.headers.cookie), AUTH_COOKIE);
311
+ const candidates = [
312
+ bearer,
313
+ firstHeader(req.headers["x-nodeui-token"]),
314
+ cookieToken,
315
+ queryToken ?? void 0
316
+ ];
317
+ const matched = candidates.find((c) => c !== void 0 && tokenEquals(c, options.authToken));
318
+ if (matched === void 0) {
319
+ return deny(401, "unauthorized", "a valid nodeui token is required");
320
+ }
321
+ if (queryToken !== null && matched === queryToken) {
322
+ return { ok: true, setToken: queryToken };
323
+ }
324
+ }
325
+ return { ok: true };
326
+ };
327
+ }
328
+
329
+ // src/confirmations.ts
330
+ var import_node_crypto2 = require("crypto");
147
331
  var ConfirmationStore = class {
148
- constructor(ttlMs) {
332
+ constructor(ttlMs, maxOutstanding = 100) {
149
333
  this.ttlMs = ttlMs;
334
+ this.maxOutstanding = maxOutstanding;
150
335
  }
151
336
  ttlMs;
337
+ maxOutstanding;
152
338
  nonces = /* @__PURE__ */ new Map();
153
339
  issue() {
154
340
  this.prune();
155
- const nonce = (0, import_node_crypto.randomBytes)(16).toString("hex");
341
+ while (this.nonces.size >= this.maxOutstanding) {
342
+ const oldest = this.nonces.keys().next().value;
343
+ if (oldest === void 0) break;
344
+ this.nonces.delete(oldest);
345
+ }
346
+ const nonce = (0, import_node_crypto2.randomBytes)(16).toString("hex");
156
347
  const expiresAtMs = Date.now() + this.ttlMs;
157
348
  this.nonces.set(nonce, expiresAtMs);
158
349
  return { nonce, expiresAtMs, ttlMs: this.ttlMs };
@@ -464,15 +655,65 @@ var EventLoopLagProvider = class {
464
655
  // src/providers/health.ts
465
656
  var LAG_OK_MS = 50;
466
657
  var LAG_DEGRADED_MS = 200;
658
+ var CHECK_TIMEOUT_MS = 3e3;
659
+ var CHECK_CACHE_MS = 5e3;
660
+ function withTimeout(promise, ms) {
661
+ return new Promise((resolve3, reject) => {
662
+ const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
663
+ promise.then(
664
+ (value) => {
665
+ clearTimeout(timer);
666
+ resolve3(value);
667
+ },
668
+ (err) => {
669
+ clearTimeout(timer);
670
+ reject(err instanceof Error ? err : new Error(String(err)));
671
+ }
672
+ );
673
+ });
674
+ }
467
675
  var HealthProvider = class {
676
+ constructor(checks = {}) {
677
+ this.checks = checks;
678
+ }
679
+ checks;
468
680
  id = "health";
469
- get(ctx) {
681
+ cache = null;
682
+ async runChecks() {
683
+ const names = Object.keys(this.checks);
684
+ if (names.length === 0) return [];
685
+ if (this.cache && Date.now() - this.cache.at < CHECK_CACHE_MS) return this.cache.results;
686
+ const results = await Promise.all(
687
+ names.map(async (name) => {
688
+ const started = Date.now();
689
+ try {
690
+ const outcome = await withTimeout(
691
+ Promise.resolve().then(() => this.checks[name]()),
692
+ CHECK_TIMEOUT_MS
693
+ );
694
+ if (outcome === false) throw new Error("check returned false");
695
+ return { name, status: "up", durationMs: Date.now() - started };
696
+ } catch (err) {
697
+ return {
698
+ name,
699
+ status: "down",
700
+ durationMs: Date.now() - started,
701
+ error: err instanceof Error ? err.message : String(err)
702
+ };
703
+ }
704
+ })
705
+ );
706
+ this.cache = { at: Date.now(), results };
707
+ return results;
708
+ }
709
+ async get(ctx) {
470
710
  const loop = ctx.store["event-loop"];
471
711
  const memory = ctx.store.memory;
472
712
  const lagMs = typeof loop?.currentMs === "number" ? loop.currentMs : null;
473
713
  const totalMem = typeof memory?.totalMem === "number" ? memory.totalMem : 0;
474
714
  const rss = typeof memory?.rss === "number" ? memory.rss : 0;
475
715
  const memoryUsedPercent = totalMem > 0 ? rss / totalMem * 100 : null;
716
+ const checks = await this.runChecks();
476
717
  let status;
477
718
  let statusReason;
478
719
  if (lagMs === null) {
@@ -488,6 +729,11 @@ var HealthProvider = class {
488
729
  status = "critical";
489
730
  statusReason = `event-loop lag ${lagMs.toFixed(1)}ms above ${LAG_DEGRADED_MS}ms`;
490
731
  }
732
+ const down = checks.filter((c) => c.status === "down");
733
+ if (down.length > 0) {
734
+ status = "critical";
735
+ statusReason = `dependency down: ${down.map((c) => c.name).join(", ")}`;
736
+ }
491
737
  return {
492
738
  ok: true,
493
739
  data: {
@@ -498,19 +744,202 @@ var HealthProvider = class {
498
744
  nodeVersion: process.version,
499
745
  platform: process.platform,
500
746
  eventLoopLagMs: lagMs,
501
- memoryUsedPercent
747
+ memoryUsedPercent,
748
+ checks
502
749
  }
503
750
  };
504
751
  }
505
752
  };
506
753
 
754
+ // src/providers/outgoing.ts
755
+ var import_node_diagnostics_channel = __toESM(require("diagnostics_channel"));
756
+ var import_node_http = __toESM(require("http"));
757
+ var import_node_https = __toESM(require("https"));
758
+ var import_node_module = require("module");
759
+ var RESPONSE_PAGE_SIZE = 100;
760
+ var recorders = /* @__PURE__ */ new Set();
761
+ var originals = /* @__PURE__ */ new Map();
762
+ var undiciUnsubscribe = null;
763
+ function describeTarget(defaultProtocol, args) {
764
+ try {
765
+ let method = "GET";
766
+ let host = "unknown";
767
+ let path = "/";
768
+ const [first, second] = args;
769
+ const opts = typeof first === "string" || first instanceof URL ? second : first;
770
+ if (typeof first === "string" || first instanceof URL) {
771
+ const parsed = new URL(first.toString());
772
+ host = parsed.host;
773
+ path = parsed.pathname;
774
+ }
775
+ if (opts && typeof opts === "object") {
776
+ if (typeof opts.method === "string") method = opts.method.toUpperCase();
777
+ if (typeof first !== "string" && !(first instanceof URL)) {
778
+ const name = opts.hostname ?? opts.host ?? "localhost";
779
+ host = opts.port ? `${name}:${String(opts.port)}` : name;
780
+ path = typeof opts.path === "string" ? opts.path.split("?")[0] ?? "/" : "/";
781
+ const protocol = typeof opts.protocol === "string" ? opts.protocol : defaultProtocol;
782
+ return { method, url: `${protocol}//${host}${path}` };
783
+ }
784
+ }
785
+ return { method, url: `${defaultProtocol}//${host}${path}` };
786
+ } catch {
787
+ return null;
788
+ }
789
+ }
790
+ function emit(entry) {
791
+ for (const record of recorders) {
792
+ try {
793
+ record(entry);
794
+ } catch {
795
+ }
796
+ }
797
+ }
798
+ function wrapModule(mod, protocol) {
799
+ for (const name of ["request", "get"]) {
800
+ const key = `${protocol}${name}`;
801
+ const original = mod[name];
802
+ originals.set(key, original);
803
+ const wrapped = function(...args) {
804
+ const req = original.apply(this, args);
805
+ const target = recorders.size > 0 ? describeTarget(protocol, args) : null;
806
+ if (target && req && typeof req.once === "function") {
807
+ const started = process.hrtime.bigint();
808
+ const timestampMs = Date.now();
809
+ let done = false;
810
+ const finish = (status, error) => {
811
+ if (done) return;
812
+ done = true;
813
+ emit({
814
+ ...target,
815
+ status,
816
+ durationMs: Number(process.hrtime.bigint() - started) / 1e6,
817
+ timestampMs,
818
+ ...error ? { error } : {}
819
+ });
820
+ };
821
+ req.once("response", (res) => {
822
+ const status = res.statusCode ?? null;
823
+ res.once("end", () => finish(status));
824
+ res.once("close", () => finish(status));
825
+ });
826
+ req.once("error", (err) => finish(null, err.message));
827
+ }
828
+ return req;
829
+ };
830
+ mod[name] = wrapped;
831
+ }
832
+ (0, import_node_module.syncBuiltinESMExports)();
833
+ }
834
+ function unwrapModule(mod, protocol) {
835
+ for (const name of ["request", "get"]) {
836
+ const original = originals.get(`${protocol}${name}`);
837
+ if (original) mod[name] = original;
838
+ originals.delete(`${protocol}${name}`);
839
+ }
840
+ (0, import_node_module.syncBuiltinESMExports)();
841
+ }
842
+ function subscribeUndici() {
843
+ const started = /* @__PURE__ */ new WeakMap();
844
+ const statuses = /* @__PURE__ */ new WeakMap();
845
+ const target = (req) => ({
846
+ method: (req.method ?? "GET").toUpperCase(),
847
+ url: `${String(req.origin ?? "")}${(req.path ?? "/").split("?")[0]}`
848
+ });
849
+ const onCreate = (message) => {
850
+ const request = message.request;
851
+ if (request) started.set(request, { at: process.hrtime.bigint(), ts: Date.now() });
852
+ };
853
+ const onHeaders = (message) => {
854
+ const { request, response } = message;
855
+ if (request && typeof response?.statusCode === "number") {
856
+ statuses.set(request, response.statusCode);
857
+ }
858
+ };
859
+ const finish = (request, error) => {
860
+ if (!request) return;
861
+ const begin = started.get(request);
862
+ if (!begin) return;
863
+ started.delete(request);
864
+ emit({
865
+ ...target(request),
866
+ status: statuses.get(request) ?? null,
867
+ durationMs: Number(process.hrtime.bigint() - begin.at) / 1e6,
868
+ timestampMs: begin.ts,
869
+ ...error ? { error } : {}
870
+ });
871
+ };
872
+ const onTrailers = (message) => finish(message.request);
873
+ const onError = (message) => {
874
+ const { request, error } = message;
875
+ finish(request, error?.message ?? "request failed");
876
+ };
877
+ import_node_diagnostics_channel.default.subscribe("undici:request:create", onCreate);
878
+ import_node_diagnostics_channel.default.subscribe("undici:request:headers", onHeaders);
879
+ import_node_diagnostics_channel.default.subscribe("undici:request:trailers", onTrailers);
880
+ import_node_diagnostics_channel.default.subscribe("undici:request:error", onError);
881
+ return () => {
882
+ import_node_diagnostics_channel.default.unsubscribe("undici:request:create", onCreate);
883
+ import_node_diagnostics_channel.default.unsubscribe("undici:request:headers", onHeaders);
884
+ import_node_diagnostics_channel.default.unsubscribe("undici:request:trailers", onTrailers);
885
+ import_node_diagnostics_channel.default.unsubscribe("undici:request:error", onError);
886
+ };
887
+ }
888
+ function interceptOutgoing(record) {
889
+ const first = recorders.size === 0;
890
+ recorders.add(record);
891
+ if (first) {
892
+ wrapModule(import_node_http.default, "http:");
893
+ wrapModule(import_node_https.default, "https:");
894
+ try {
895
+ undiciUnsubscribe = subscribeUndici();
896
+ } catch {
897
+ undiciUnsubscribe = null;
898
+ }
899
+ }
900
+ return () => {
901
+ if (!recorders.delete(record) || recorders.size > 0) return;
902
+ unwrapModule(import_node_http.default, "http:");
903
+ unwrapModule(import_node_https.default, "https:");
904
+ undiciUnsubscribe?.();
905
+ undiciUnsubscribe = null;
906
+ };
907
+ }
908
+ var OutgoingProvider = class {
909
+ id = "outgoing";
910
+ buffer;
911
+ nextId = 1;
912
+ failed = 0;
913
+ release = null;
914
+ constructor(size) {
915
+ this.buffer = new RingBuffer(size);
916
+ }
917
+ start() {
918
+ if (this.release) return;
919
+ this.release = interceptOutgoing((entry) => this.record(entry));
920
+ }
921
+ stop() {
922
+ this.release?.();
923
+ this.release = null;
924
+ }
925
+ record(entry) {
926
+ this.buffer.push({ id: this.nextId, ...entry });
927
+ this.nextId += 1;
928
+ if (entry.error || entry.status !== null && entry.status >= 500) this.failed += 1;
929
+ }
930
+ get() {
931
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE));
932
+ return { ok: true, data: { total: this.nextId - 1, failed: this.failed, entries } };
933
+ }
934
+ };
935
+
507
936
  // src/providers/heap-snapshot.ts
508
937
  var import_promises = require("fs/promises");
509
938
  var import_node_os2 = require("os");
510
939
  var import_node_path2 = require("path");
511
940
  var import_node_v8 = require("v8");
512
941
  function defaultSnapshotDir() {
513
- return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_os2.tmpdir)();
942
+ return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "nodeui-heap");
514
943
  }
515
944
  var HeapSnapshotProvider = class {
516
945
  id = "heap-snapshot";
@@ -524,8 +953,9 @@ var HeapSnapshotProvider = class {
524
953
  const fileName = `nodeui-heap-${process.pid}-${stamp}.heapsnapshot`;
525
954
  const filePath = (0, import_node_path2.join)(dir, fileName);
526
955
  try {
527
- await (0, import_promises.mkdir)(dir, { recursive: true });
956
+ await (0, import_promises.mkdir)(dir, { recursive: true, mode: 448 });
528
957
  (0, import_node_v8.writeHeapSnapshot)(filePath);
958
+ await (0, import_promises.chmod)(filePath, 384).catch(() => void 0);
529
959
  const fileStat = await (0, import_promises.stat)(filePath);
530
960
  const data = {
531
961
  fileName,
@@ -548,7 +978,55 @@ var HeapSnapshotProvider = class {
548
978
  };
549
979
 
550
980
  // src/providers/requests.ts
551
- var RESPONSE_PAGE_SIZE = 100;
981
+ var RESPONSE_PAGE_SIZE2 = 100;
982
+ var MAX_ROUTE_STATS = 8;
983
+ function percentile(sorted, p) {
984
+ if (sorted.length === 0) return 0;
985
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
986
+ return sorted[index] ?? 0;
987
+ }
988
+ function routeKey(path) {
989
+ return path.split("/").map(
990
+ (seg) => /^\d+$/.test(seg) || /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(seg) || /^[0-9a-f]{24}$/i.test(seg) ? ":id" : seg
991
+ ).join("/");
992
+ }
993
+ function summarizeRequests(entries) {
994
+ const durations = entries.map((e) => e.durationMs).sort((a, b) => a - b);
995
+ const byStatus = { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0 };
996
+ const groups = /* @__PURE__ */ new Map();
997
+ for (const e of entries) {
998
+ const bucket = `${Math.floor(e.status / 100)}xx`;
999
+ if (bucket in byStatus) byStatus[bucket] += 1;
1000
+ const path = routeKey(e.path);
1001
+ const key = `${e.method} ${path}`;
1002
+ const group = groups.get(key) ?? { method: e.method, path, ms: [], errors: 0 };
1003
+ group.ms.push(e.durationMs);
1004
+ if (e.status >= 500) group.errors += 1;
1005
+ groups.set(key, group);
1006
+ }
1007
+ const routes = [...groups.values()].map((g) => {
1008
+ const sorted = [...g.ms].sort((a, b) => a - b);
1009
+ return {
1010
+ method: g.method,
1011
+ path: g.path,
1012
+ count: g.ms.length,
1013
+ avgMs: g.ms.reduce((a, b) => a + b, 0) / g.ms.length,
1014
+ p95Ms: percentile(sorted, 0.95),
1015
+ errors: g.errors
1016
+ };
1017
+ }).sort((a, b) => b.p95Ms - a.p95Ms).slice(0, MAX_ROUTE_STATS);
1018
+ return {
1019
+ count: entries.length,
1020
+ errors: byStatus["5xx"],
1021
+ errorRate: entries.length === 0 ? 0 : byStatus["5xx"] / entries.length,
1022
+ avgMs: durations.length === 0 ? 0 : durations.reduce((a, b) => a + b, 0) / durations.length,
1023
+ p50Ms: percentile(durations, 0.5),
1024
+ p95Ms: percentile(durations, 0.95),
1025
+ p99Ms: percentile(durations, 0.99),
1026
+ byStatus,
1027
+ routes
1028
+ };
1029
+ }
552
1030
  var RequestsProvider = class {
553
1031
  id = "requests";
554
1032
  buffer;
@@ -564,8 +1042,9 @@ var RequestsProvider = class {
564
1042
  return this.buffer.length;
565
1043
  }
566
1044
  get() {
567
- const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE));
568
- return { ok: true, data: { total: this.buffer.length, entries } };
1045
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE2));
1046
+ const summary = summarizeRequests(this.buffer.toArray());
1047
+ return { ok: true, data: { total: this.buffer.length, entries, summary } };
569
1048
  }
570
1049
  };
571
1050
 
@@ -679,6 +1158,11 @@ var RoutesProvider = class {
679
1158
  id = "routes";
680
1159
  cache = /* @__PURE__ */ new WeakMap();
681
1160
  get(ctx) {
1161
+ const source = ctx.store["routes-source"];
1162
+ if (source !== void 0) {
1163
+ const routes2 = typeof source === "function" ? source() : source;
1164
+ return { ok: true, data: { routes: routes2 } };
1165
+ }
682
1166
  const router = ctx.store["express-router"];
683
1167
  if (!router) {
684
1168
  return {
@@ -727,14 +1211,14 @@ function formatArg(arg) {
727
1211
  var refs = 0;
728
1212
  var pushes = /* @__PURE__ */ new Set();
729
1213
  var wrappers = /* @__PURE__ */ new Map();
730
- var originals = {};
1214
+ var originals2 = {};
731
1215
  function consoleRef() {
732
1216
  return console;
733
1217
  }
734
1218
  function installWrappers() {
735
1219
  for (const [method, level] of CONSOLE_METHODS) {
736
1220
  const original = consoleRef()[method];
737
- originals[method] = original;
1221
+ originals2[method] = original;
738
1222
  const wrapper = (...args) => {
739
1223
  for (const push of pushes) {
740
1224
  try {
@@ -752,7 +1236,7 @@ function uninstallWrappers() {
752
1236
  for (const [method] of CONSOLE_METHODS) {
753
1237
  const current = consoleRef()[method];
754
1238
  if (current === wrappers.get(method)) {
755
- const original = originals[method];
1239
+ const original = originals2[method];
756
1240
  if (original) {
757
1241
  consoleRef()[method] = original;
758
1242
  } else {
@@ -761,7 +1245,7 @@ function uninstallWrappers() {
761
1245
  }
762
1246
  }
763
1247
  wrappers.clear();
764
- originals = {};
1248
+ originals2 = {};
765
1249
  }
766
1250
  function interceptConsole(push) {
767
1251
  refs += 1;
@@ -808,9 +1292,10 @@ var LogsProvider = class {
808
1292
  };
809
1293
 
810
1294
  // src/sse.ts
811
- function startSse(res) {
1295
+ function startSse(res, extraHeaders = {}) {
812
1296
  let closed = false;
813
1297
  res.writeHead(200, {
1298
+ ...extraHeaders,
814
1299
  "Content-Type": "text/event-stream",
815
1300
  "Cache-Control": "no-cache",
816
1301
  Connection: "keep-alive",
@@ -841,8 +1326,35 @@ var import_node_os3 = require("os");
841
1326
  var import_node_path3 = require("path");
842
1327
  var STATIC_ROOT = (0, import_node_path3.resolve)(__dirname, "..", "static");
843
1328
  var SSE_HEARTBEAT_MS = 15e3;
844
- function serializeEnvelope(envelope) {
845
- return JSON.stringify(maskSecrets(envelope));
1329
+ function serializeEnvelope(envelope, mask = true) {
1330
+ return JSON.stringify(mask ? maskSecrets(envelope) : envelope);
1331
+ }
1332
+ var BUILT_IN_PANELS = /* @__PURE__ */ new Set([
1333
+ "health",
1334
+ "memory",
1335
+ "cpu",
1336
+ "event-loop",
1337
+ "heap-snapshot",
1338
+ "startup",
1339
+ "requests",
1340
+ "env",
1341
+ "routes",
1342
+ "logs",
1343
+ "metrics",
1344
+ "outgoing"
1345
+ ]);
1346
+ var PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1347
+ var RESERVED_API_NAMES = /* @__PURE__ */ new Set(["config", "live", "confirmations"]);
1348
+ var SECURITY_HEADERS = {
1349
+ "X-Content-Type-Options": "nosniff",
1350
+ "X-Frame-Options": "DENY",
1351
+ "Referrer-Policy": "no-referrer",
1352
+ "Cross-Origin-Resource-Policy": "same-origin"
1353
+ };
1354
+ 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'";
1355
+ function listFrom(value, raw) {
1356
+ if (value) return value;
1357
+ return (raw ?? "").split(",").map((v) => v.trim()).filter(Boolean);
846
1358
  }
847
1359
  function pathnameOf(req) {
848
1360
  const raw = req.originalUrl ?? req.url ?? "/";
@@ -902,19 +1414,46 @@ function createNodeUI(options = {}) {
902
1414
  env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
903
1415
  "NODEUI_CONFIRM_TTL_MS"
904
1416
  ),
905
- heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_os3.tmpdir)()
1417
+ heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_path3.join)((0, import_node_os3.tmpdir)(), "nodeui-heap"),
1418
+ allowedHosts: listFrom(options.allowedHosts, env.NODEUI_ALLOWED_HOSTS),
1419
+ allowedOrigins: listFrom(options.allowedOrigins, env.NODEUI_ALLOWED_ORIGINS),
1420
+ allowedRemoteAddresses: listFrom(options.allowedRemoteAddresses, env.NODEUI_ALLOWED_REMOTE),
1421
+ trustProxy: options.trustProxy ?? env.NODEUI_TRUST_PROXY === "true",
1422
+ authRequired: Boolean(options.authToken ?? env.NODEUI_TOKEN),
1423
+ maxSseClients: positiveInt(
1424
+ options.maxSseClients,
1425
+ env.NODEUI_MAX_SSE_CLIENTS ? Number(env.NODEUI_MAX_SSE_CLIENTS) : 10,
1426
+ "NODEUI_MAX_SSE_CLIENTS"
1427
+ )
906
1428
  };
907
- if (!isLoopbackAddress(config.host)) {
1429
+ const authToken = options.authToken ?? env.NODEUI_TOKEN;
1430
+ const guard = createGuard({
1431
+ host: config.host,
1432
+ allowedHosts: config.allowedHosts,
1433
+ allowedOrigins: config.allowedOrigins,
1434
+ allowedRemoteAddresses: config.allowedRemoteAddresses,
1435
+ trustProxy: config.trustProxy,
1436
+ authToken: authToken || void 0
1437
+ });
1438
+ if (config.enabled && !isLoopbackAddress(config.host)) {
908
1439
  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.`
1440
+ `[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
1441
  );
911
1442
  }
1443
+ if (config.enabled && env.NODE_ENV === "production") {
1444
+ console.warn(
1445
+ "[nodeui] running with NODE_ENV=production. The console exposes environment, logs and heap data; keep it behind loopback and an auth token."
1446
+ );
1447
+ }
1448
+ if (config.enabled && !config.maskSecrets) {
1449
+ console.warn("[nodeui] secret masking is disabled; panels may show credentials.");
1450
+ }
912
1451
  const ctx = { config, env, store: {} };
913
1452
  if (options.config !== void 0) {
914
1453
  ctx.store["app-config"] = options.config;
915
1454
  }
916
1455
  const registry = new ProviderRegistry();
917
- registry.register(new HealthProvider());
1456
+ registry.register(new HealthProvider(options.healthChecks));
918
1457
  registry.register(new MemoryProvider());
919
1458
  registry.register(new CpuProvider());
920
1459
  registry.register(new EventLoopLagProvider());
@@ -936,9 +1475,23 @@ function createNodeUI(options = {}) {
936
1475
  registry.register(envProvider);
937
1476
  const routesProvider = new RoutesProvider();
938
1477
  registry.register(routesProvider);
1478
+ registry.register(
1479
+ new OutgoingProvider(positiveInt(options.outgoingLogSize, 200, "outgoingLogSize"))
1480
+ );
1481
+ const pluginMeta = [];
1482
+ for (const plugin of options.plugins ?? []) {
1483
+ if (!PLUGIN_ID_PATTERN.test(plugin.id) || BUILT_IN_PANELS.has(plugin.id) || RESERVED_API_NAMES.has(plugin.id) || registry.get(plugin.id)) {
1484
+ throw new Error(
1485
+ `nodeui plugin id "${plugin.id}" is invalid or already taken (use lowercase letters, digits and "-")`
1486
+ );
1487
+ }
1488
+ registry.register(plugin);
1489
+ pluginMeta.push({ id: plugin.id, title: plugin.title ?? plugin.id });
1490
+ }
939
1491
  const confirmations = new ConfirmationStore(config.confirmTtlMs);
940
1492
  const activeProviders = /* @__PURE__ */ new Set();
941
1493
  let inactivityTimer = null;
1494
+ let sseClients = 0;
942
1495
  function stopAll() {
943
1496
  for (const id of activeProviders) {
944
1497
  registry.get(id)?.stop?.(ctx);
@@ -962,8 +1515,9 @@ function createNodeUI(options = {}) {
962
1515
  resetInactivityTimer();
963
1516
  }
964
1517
  function sendJson(res, status, envelope) {
965
- const body = serializeEnvelope(envelope);
1518
+ const body = serializeEnvelope(envelope, config.maskSecrets);
966
1519
  res.writeHead(status, {
1520
+ ...SECURITY_HEADERS,
967
1521
  "Content-Type": "application/json",
968
1522
  "Content-Length": Buffer.byteLength(body),
969
1523
  "Cache-Control": "no-store"
@@ -981,8 +1535,24 @@ function createNodeUI(options = {}) {
981
1535
  const provider = registry.get(id);
982
1536
  if (!provider) return notFound(res);
983
1537
  const requestCtx = query ? { ...ctx, query } : ctx;
984
- const result = await provider.get(requestCtx);
985
- sendJson(res, result.ok ? 200 : 500, result);
1538
+ sendJson(res, ...await readPanel(provider, requestCtx));
1539
+ }
1540
+ async function readPanel(provider, requestCtx) {
1541
+ try {
1542
+ const result = await provider.get(requestCtx);
1543
+ return [result.ok ? 200 : 500, result];
1544
+ } catch (err) {
1545
+ return [
1546
+ 500,
1547
+ {
1548
+ ok: false,
1549
+ error: {
1550
+ code: "provider-failed",
1551
+ message: err instanceof Error ? err.message : "provider failed"
1552
+ }
1553
+ }
1554
+ ];
1555
+ }
986
1556
  }
987
1557
  function configData() {
988
1558
  return {
@@ -995,7 +1565,9 @@ function createNodeUI(options = {}) {
995
1565
  logSize: config.logSize,
996
1566
  pollIntervalMs: config.pollIntervalMs,
997
1567
  panels: registry.ids(),
998
- masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source }
1568
+ masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source },
1569
+ locked: config.authRequired,
1570
+ plugins: pluginMeta
999
1571
  };
1000
1572
  }
1001
1573
  function issueConfirmation(res) {
@@ -1027,22 +1599,37 @@ function createNodeUI(options = {}) {
1027
1599
  const requested = (search.get("panels") ?? "").split(",").filter(Boolean);
1028
1600
  const panels = (requested.length > 0 ? requested.filter((id) => registry.get(id) !== void 0) : registry.ids()).filter((value, index, self) => self.indexOf(value) === index);
1029
1601
  if (panels.length === 0) return notFound(res);
1030
- const stream = startSse(res);
1602
+ if (sseClients >= config.maxSseClients) {
1603
+ res.writeHead(429, { ...SECURITY_HEADERS, "Content-Type": "application/json" });
1604
+ res.end(
1605
+ JSON.stringify({
1606
+ ok: false,
1607
+ error: { code: "too-many-streams", message: "too many open live streams" }
1608
+ })
1609
+ );
1610
+ return;
1611
+ }
1612
+ sseClients += 1;
1613
+ const stream = startSse(res, SECURITY_HEADERS);
1031
1614
  const push = async () => {
1032
1615
  for (const id of panels) {
1033
1616
  ensureActive(id);
1034
1617
  const provider = registry.get(id);
1035
1618
  if (!provider) continue;
1036
- const result = await provider.get(ctx);
1037
- stream.send({ panel: id, envelope: result });
1619
+ const [, result] = await readPanel(provider, ctx);
1620
+ stream.send({ panel: id, envelope: config.maskSecrets ? maskSecrets(result) : result });
1038
1621
  }
1039
1622
  };
1040
- void push();
1041
- const pushTimer = setInterval(() => void push(), config.pollIntervalMs);
1623
+ void push().catch(() => void 0);
1624
+ const pushTimer = setInterval(() => void push().catch(() => void 0), config.pollIntervalMs);
1042
1625
  const heartbeatTimer = setInterval(() => stream.heartbeat(), SSE_HEARTBEAT_MS);
1043
1626
  if (typeof pushTimer.unref === "function") pushTimer.unref();
1044
1627
  if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
1628
+ let cleaned = false;
1045
1629
  const cleanup = () => {
1630
+ if (cleaned) return;
1631
+ cleaned = true;
1632
+ sseClients -= 1;
1046
1633
  clearInterval(pushTimer);
1047
1634
  clearInterval(heartbeatTimer);
1048
1635
  stream.close();
@@ -1085,30 +1672,33 @@ function createNodeUI(options = {}) {
1085
1672
  return;
1086
1673
  }
1087
1674
  res.writeHead(200, {
1675
+ ...SECURITY_HEADERS,
1676
+ "Content-Security-Policy": CSP,
1088
1677
  "Content-Type": asset.contentType,
1089
1678
  "Content-Length": asset.length,
1090
1679
  "Cache-Control": "no-cache"
1091
1680
  });
1092
1681
  asset.content.pipe(res);
1093
1682
  }
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
1683
  async function handleNodeUIPath(req, res, urlPath) {
1111
- if (!guardLoopback(req, res)) return;
1684
+ const verdict = guard(req);
1685
+ if (!verdict.ok) {
1686
+ sendJson(res, verdict.status, {
1687
+ ok: false,
1688
+ error: { code: verdict.code, message: verdict.message }
1689
+ });
1690
+ return;
1691
+ }
1692
+ if (verdict.setToken !== void 0) {
1693
+ res.writeHead(302, {
1694
+ ...SECURITY_HEADERS,
1695
+ "Set-Cookie": `${AUTH_COOKIE}=${encodeURIComponent(verdict.setToken)}; Path=${path}; HttpOnly; SameSite=Strict`,
1696
+ Location: `${path}/`,
1697
+ "Cache-Control": "no-store"
1698
+ });
1699
+ res.end();
1700
+ return;
1701
+ }
1112
1702
  const apiBase = `${path}/api`;
1113
1703
  if (urlPath === apiBase || urlPath.startsWith(`${apiBase}/`)) {
1114
1704
  await handleApi(req, res, urlPath.slice(apiBase.length));
@@ -1192,6 +1782,9 @@ function createNodeUI(options = {}) {
1192
1782
  }
1193
1783
  notFound(res);
1194
1784
  },
1785
+ setRoutes(source) {
1786
+ ctx.store["routes-source"] = source;
1787
+ },
1195
1788
  mark(name) {
1196
1789
  startupTracker.mark(name);
1197
1790
  },
@@ -1209,6 +1802,7 @@ function createNodeUI(options = {}) {
1209
1802
  }
1210
1803
  // Annotate the CommonJS export names for ESM import in node:
1211
1804
  0 && (module.exports = {
1805
+ AUTH_COOKIE,
1212
1806
  ConfirmationStore,
1213
1807
  CpuProvider,
1214
1808
  DEFAULT_LOG_SIZE,
@@ -1219,6 +1813,7 @@ function createNodeUI(options = {}) {
1219
1813
  LogsProvider,
1220
1814
  MemoryProvider,
1221
1815
  MetricsProvider,
1816
+ OutgoingProvider,
1222
1817
  ProviderRegistry,
1223
1818
  RequestsProvider,
1224
1819
  RingBuffer,
@@ -1227,14 +1822,21 @@ function createNodeUI(options = {}) {
1227
1822
  SECRET_MASKED,
1228
1823
  Sampler,
1229
1824
  StartupTracker,
1825
+ createGuard,
1230
1826
  createNodeUI,
1231
1827
  extractRoutes,
1828
+ hostnameFromHostHeader,
1232
1829
  interceptConsole,
1830
+ interceptOutgoing,
1233
1831
  isLoopbackAddress,
1832
+ isLoopbackHostname,
1833
+ maskSecretText,
1234
1834
  maskSecrets,
1835
+ matchesAddress,
1235
1836
  resolveActivation,
1236
1837
  resolveStaticAsset,
1237
1838
  serializeEnvelope,
1238
- startSse
1839
+ startSse,
1840
+ summarizeRequests
1239
1841
  });
1240
1842
  //# sourceMappingURL=index.cjs.map