@singhak/nodeui-core 0.3.0 → 0.5.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
@@ -57,7 +57,10 @@ var BEARER_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*/gi;
57
57
  var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g;
58
58
  function maskSecretText(text) {
59
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}`);
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, (_m, name, sep2, value) => {
61
+ const quote = value.startsWith('"') || value.startsWith("'") ? value[0] : "";
62
+ return `${name}${sep2}${quote}${SECRET_MASKED}${quote}`;
63
+ });
61
64
  }
62
65
  function resolveActivation(env) {
63
66
  if (env.NODEUI_ENABLED === "true") {
@@ -95,21 +98,91 @@ function ipv4ToInt(ip) {
95
98
  }
96
99
  return out;
97
100
  }
101
+ function ipv6ToBigInt(address) {
102
+ const text = address.split("%")[0] ?? "";
103
+ if (!text.includes(":")) return null;
104
+ let head = text;
105
+ let tailGroups = [];
106
+ const doubleColon = text.indexOf("::");
107
+ if (doubleColon !== -1) {
108
+ if (text.indexOf("::", doubleColon + 1) !== -1) return null;
109
+ head = text.slice(0, doubleColon);
110
+ const tail = text.slice(doubleColon + 2);
111
+ tailGroups = tail === "" ? [] : tail.split(":");
112
+ }
113
+ const headGroups = doubleColon !== -1 ? head === "" ? [] : head.split(":") : text.split(":");
114
+ const groups = doubleColon !== -1 ? [...headGroups, ...tailGroups] : headGroups;
115
+ const last = groups[groups.length - 1];
116
+ const v4 = last?.includes(".") ? ipv4ToInt(last) : null;
117
+ if (last?.includes(".")) {
118
+ if (v4 === null) return null;
119
+ groups.splice(
120
+ groups.length - 1,
121
+ 1,
122
+ (v4 >>> 16 & 65535).toString(16),
123
+ (v4 & 65535).toString(16)
124
+ );
125
+ }
126
+ const expected = 8;
127
+ if (doubleColon === -1 ? groups.length !== expected : groups.length >= expected) return null;
128
+ const parseGroup = (g) => /^[0-9a-f]{1,4}$/i.test(g) ? BigInt(`0x${g}`) : null;
129
+ const headLen = doubleColon !== -1 ? headGroups.length : groups.length;
130
+ const fill = expected - groups.length;
131
+ const full = [
132
+ ...groups.slice(0, headLen),
133
+ ...Array(doubleColon !== -1 ? fill : 0).fill("0"),
134
+ ...groups.slice(headLen)
135
+ ];
136
+ let out = 0n;
137
+ for (const g of full) {
138
+ const n = parseGroup(g);
139
+ if (n === null) return null;
140
+ out = out << 16n | n;
141
+ }
142
+ return out;
143
+ }
144
+ function isValidAddressEntry(entry) {
145
+ const normalized = normalizeIp(entry);
146
+ if (!normalized.includes("/"))
147
+ return ipv4ToInt(normalized) !== null || ipv6ToBigInt(normalized) !== null;
148
+ const [base = "", bitsRaw = ""] = normalized.split("/");
149
+ const bits = Number(bitsRaw);
150
+ if (!/^\d{1,3}$/.test(bitsRaw) || !Number.isInteger(bits)) return false;
151
+ if (ipv4ToInt(base) !== null) return bits <= 32;
152
+ return ipv6ToBigInt(base) !== null && bits <= 128;
153
+ }
98
154
  function matchesAddress(address, allowed) {
99
155
  if (!address) return false;
100
156
  const ip = normalizeIp(address);
101
- for (const entry of allowed) {
157
+ const ip4 = ipv4ToInt(ip);
158
+ const ip6 = ip4 === null ? ipv6ToBigInt(ip) : null;
159
+ for (const raw of allowed) {
160
+ const entry = normalizeIp(raw);
102
161
  if (!entry.includes("/")) {
103
- if (normalizeIp(entry) === ip) return true;
162
+ if (entry === ip) return true;
163
+ const e4 = ipv4ToInt(entry);
164
+ if (ip4 !== null && e4 !== null) {
165
+ if (ip4 === e4) return true;
166
+ continue;
167
+ }
168
+ const e6 = ipv6ToBigInt(entry);
169
+ if (ip6 !== null && e6 !== null && ip6 === e6) return true;
104
170
  continue;
105
171
  }
106
- const [base, bitsRaw] = entry.split("/");
172
+ const [base = "", bitsRaw = ""] = entry.split("/");
173
+ if (!/^\d{1,3}$/.test(bitsRaw)) continue;
107
174
  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;
175
+ const b4 = ipv4ToInt(base);
176
+ if (b4 !== null) {
177
+ if (ip4 === null || bits > 32) continue;
178
+ const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
179
+ if ((ip4 & mask) >>> 0 === (b4 & mask) >>> 0) return true;
180
+ continue;
181
+ }
182
+ const b6 = ipv6ToBigInt(base);
183
+ if (b6 === null || ip6 === null || bits > 128) continue;
184
+ const shift = BigInt(128 - bits);
185
+ if (ip6 >> shift === b6 >> shift) return true;
113
186
  }
114
187
  return false;
115
188
  }
@@ -340,6 +413,9 @@ var MIME_TYPES = {
340
413
  ".woff2": "font/woff2",
341
414
  ".webmanifest": "application/manifest+json"
342
415
  };
416
+ function uiAssetsDir() {
417
+ return resolve(__dirname, "..", "static");
418
+ }
343
419
  function resolveStaticAsset(rootDir, urlPath) {
344
420
  const relative = urlPath === "" ? "index.html" : urlPath.replace(/^\/+/, "");
345
421
  const candidate = resolve(rootDir, relative);
@@ -594,12 +670,12 @@ var LAG_DEGRADED_MS = 200;
594
670
  var CHECK_TIMEOUT_MS = 3e3;
595
671
  var CHECK_CACHE_MS = 5e3;
596
672
  function withTimeout(promise, ms) {
597
- return new Promise((resolve3, reject) => {
673
+ return new Promise((resolve2, reject) => {
598
674
  const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
599
675
  promise.then(
600
676
  (value) => {
601
677
  clearTimeout(timer);
602
- resolve3(value);
678
+ resolve2(value);
603
679
  },
604
680
  (err) => {
605
681
  clearTimeout(timer);
@@ -687,14 +763,355 @@ var HealthProvider = class {
687
763
  }
688
764
  };
689
765
 
766
+ // src/providers/queries.ts
767
+ import { createRequire } from "module";
768
+ import { join as join2 } from "path";
769
+
770
+ // src/context.ts
771
+ import { AsyncLocalStorage } from "async_hooks";
772
+ var storage = new AsyncLocalStorage();
773
+ var ids = /* @__PURE__ */ new WeakMap();
774
+ var suppression = new AsyncLocalStorage();
775
+ function runSuppressed(fn) {
776
+ return suppression.run(true, fn);
777
+ }
778
+ function isSuppressed() {
779
+ return suppression.getStore() === true;
780
+ }
781
+ function currentRequestId() {
782
+ return storage.getStore()?.requestId;
783
+ }
784
+ function bindRequestId(req, requestId) {
785
+ ids.set(req, requestId);
786
+ }
787
+ function runWithRequestId(requestId, fn) {
788
+ return storage.run({ requestId }, fn);
789
+ }
790
+ function runInRequestContext(req, fn) {
791
+ const requestId = ids.get(req);
792
+ return requestId === void 0 ? fn() : storage.run({ requestId }, fn);
793
+ }
794
+ function enterRequestContext(req) {
795
+ const requestId = ids.get(req);
796
+ if (requestId !== void 0) storage.enterWith({ requestId });
797
+ }
798
+
799
+ // src/providers/queries.ts
800
+ var RESPONSE_PAGE_SIZE = 100;
801
+ var MAX_SQL_CHARS = 2e3;
802
+ var N_PLUS_ONE_THRESHOLD = 5;
803
+ var defaultLoader = (name) => {
804
+ try {
805
+ return createRequire(join2(process.cwd(), "noop.js"))(name);
806
+ } catch {
807
+ return null;
808
+ }
809
+ };
810
+ var recorders = /* @__PURE__ */ new Set();
811
+ var restorers = [];
812
+ function normalizeSql(sql) {
813
+ return sql.replace(/'(?:[^']|'')*'/g, "?").replace(/\b\d+(?:\.\d+)?\b/g, "?").replace(/\(\s*\?(?:\s*,\s*\?)+\s*\)/g, "(?)").replace(/\s+/g, " ").trim().toLowerCase();
814
+ }
815
+ function emit(entry) {
816
+ for (const record of recorders) {
817
+ try {
818
+ record(entry);
819
+ } catch {
820
+ }
821
+ }
822
+ }
823
+ function sqlOf(arg) {
824
+ if (typeof arg === "string") return arg;
825
+ if (arg && typeof arg === "object") {
826
+ const candidate = arg.text ?? arg.sql;
827
+ if (typeof candidate === "string") return candidate;
828
+ }
829
+ return null;
830
+ }
831
+ function rowCountOf(result) {
832
+ if (Array.isArray(result)) {
833
+ const first = result[0];
834
+ return Array.isArray(first) ? first.length : result.length;
835
+ }
836
+ if (result && typeof result === "object") {
837
+ const r = result;
838
+ if (typeof r.rowCount === "number") return r.rowCount;
839
+ if (typeof r.affectedRows === "number") return r.affectedRows;
840
+ }
841
+ return void 0;
842
+ }
843
+ function wrapQueryMethod(proto, method, system, rowCount) {
844
+ const original = proto[method];
845
+ if (typeof original !== "function") return;
846
+ const orig = original;
847
+ const wrapped = function(...args) {
848
+ if (recorders.size === 0) return orig.apply(this, args);
849
+ const sql = sqlOf(args[0]);
850
+ if (sql === null) return orig.apply(this, args);
851
+ const started = process.hrtime.bigint();
852
+ const timestampMs = Date.now();
853
+ const requestId = currentRequestId();
854
+ let done = false;
855
+ const finish = (error, result) => {
856
+ if (done) return;
857
+ done = true;
858
+ emit({
859
+ system,
860
+ sql: sql.slice(0, MAX_SQL_CHARS),
861
+ durationMs: Number(process.hrtime.bigint() - started) / 1e6,
862
+ timestampMs,
863
+ ...requestId !== void 0 ? { requestId } : {},
864
+ ...error ? { error: error instanceof Error ? error.message : String(error) } : {},
865
+ ...!error && rowCount(result) !== void 0 ? { rowCount: rowCount(result) } : {}
866
+ });
867
+ };
868
+ const last = args.length - 1;
869
+ const hasCallback = typeof args[last] === "function";
870
+ if (hasCallback) {
871
+ const callback = args[last];
872
+ args[last] = function(err, result, ...rest) {
873
+ finish(err, result);
874
+ return callback.call(this, err, result, ...rest);
875
+ };
876
+ }
877
+ const ret = orig.apply(this, args);
878
+ if (!hasCallback && ret && typeof ret.then === "function") {
879
+ ret.then(
880
+ (result) => finish(void 0, result),
881
+ (error) => finish(error ?? new Error("query failed"))
882
+ );
883
+ } else if (!hasCallback && ret && typeof ret.once === "function") {
884
+ const emitter = ret;
885
+ emitter.once("end", (result) => finish(void 0, result));
886
+ emitter.once("error", (error) => finish(error ?? new Error("query failed")));
887
+ }
888
+ return ret;
889
+ };
890
+ proto[method] = wrapped;
891
+ restorers.push(() => {
892
+ if (proto[method] === wrapped) proto[method] = original;
893
+ });
894
+ }
895
+ function instrumentDrivers(load) {
896
+ const pg = load("pg");
897
+ if (pg?.Client?.prototype) wrapQueryMethod(pg.Client.prototype, "query", "pg", rowCountOf);
898
+ const mysql2 = load("mysql2");
899
+ const proto = mysql2?.Connection?.prototype;
900
+ if (proto) {
901
+ wrapQueryMethod(proto, "query", "mysql2", rowCountOf);
902
+ wrapQueryMethod(proto, "execute", "mysql2", rowCountOf);
903
+ }
904
+ }
905
+ function interceptQueries(record, load = defaultLoader) {
906
+ const first = recorders.size === 0;
907
+ recorders.add(record);
908
+ if (first) {
909
+ try {
910
+ instrumentDrivers(load);
911
+ } catch {
912
+ }
913
+ }
914
+ return () => {
915
+ if (!recorders.delete(record) || recorders.size > 0) return;
916
+ while (restorers.length > 0) restorers.pop()?.();
917
+ };
918
+ }
919
+ var QueriesProvider = class {
920
+ constructor(options) {
921
+ this.options = options;
922
+ this.buffer = new RingBuffer(options.size);
923
+ }
924
+ options;
925
+ id = "queries";
926
+ buffer;
927
+ nextId = 1;
928
+ release = null;
929
+ failed = 0;
930
+ /** Called with every newly recorded entry (persistence hook). */
931
+ onRecord;
932
+ start() {
933
+ if (this.release) return;
934
+ this.release = interceptQueries((entry) => this.record(entry), this.options.loader);
935
+ }
936
+ stop() {
937
+ this.release?.();
938
+ this.release = null;
939
+ }
940
+ record(entry) {
941
+ const full = { id: this.nextId, ...entry };
942
+ this.buffer.push(full);
943
+ this.nextId += 1;
944
+ if (entry.error) this.failed += 1;
945
+ this.onRecord?.(full);
946
+ }
947
+ /** Re-inserts journaled entries without re-emitting them. */
948
+ restore(entries) {
949
+ for (const entry of entries) {
950
+ this.buffer.push(entry);
951
+ this.nextId = Math.max(this.nextId, entry.id + 1);
952
+ if (entry.error) this.failed += 1;
953
+ }
954
+ }
955
+ get() {
956
+ const all = this.buffer.toArray();
957
+ const counts = /* @__PURE__ */ new Map();
958
+ const keyOf = (e) => e.requestId === void 0 ? null : `${e.requestId}|${normalizeSql(e.sql)}`;
959
+ for (const e of all) {
960
+ const key = keyOf(e);
961
+ if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
962
+ }
963
+ const flagged = /* @__PURE__ */ new Set();
964
+ const entries = all.map((e) => {
965
+ const key = keyOf(e);
966
+ const repeats = key ? counts.get(key) ?? 0 : 0;
967
+ const nPlusOne = repeats >= N_PLUS_ONE_THRESHOLD;
968
+ if (nPlusOne && key) flagged.add(key);
969
+ return {
970
+ ...e,
971
+ slow: e.durationMs >= this.options.slowQueryMs,
972
+ ...nPlusOne ? { nPlusOne, repeats } : {}
973
+ };
974
+ });
975
+ return {
976
+ ok: true,
977
+ data: {
978
+ total: this.nextId - 1,
979
+ failed: this.failed,
980
+ slow: entries.filter((e) => e.slow).length,
981
+ nPlusOneGroups: flagged.size,
982
+ slowQueryMs: this.options.slowQueryMs,
983
+ entries: entries.slice(Math.max(0, entries.length - RESPONSE_PAGE_SIZE))
984
+ }
985
+ };
986
+ }
987
+ };
988
+
989
+ // src/providers/errors.ts
990
+ import { createHash as createHash2 } from "crypto";
991
+ var MAX_GROUPS = 100;
992
+ var MAX_STACK_CHARS = 4e3;
993
+ var MAX_MESSAGE_CHARS = 500;
994
+ var FINGERPRINT_FRAMES = 3;
995
+ function toError(value) {
996
+ if (value instanceof Error) {
997
+ return { name: value.name, message: value.message, stack: value.stack ?? "" };
998
+ }
999
+ let message;
1000
+ try {
1001
+ message = typeof value === "string" ? value : JSON.stringify(value);
1002
+ } catch {
1003
+ message = String(value);
1004
+ }
1005
+ return { name: "NonError", message: message ?? String(value), stack: "" };
1006
+ }
1007
+ function topFrames(stack) {
1008
+ const frames = [];
1009
+ for (const line of stack.split("\n")) {
1010
+ const match = /^\s*at\s+(?:(.+?)\s+\()?(?:.*[\\/])?([^\\/:()]+?)(?::\d+){0,2}\)?\s*$/.exec(
1011
+ line
1012
+ );
1013
+ if (match) frames.push(`${match[1] ?? ""}@${match[2] ?? ""}`);
1014
+ if (frames.length >= FINGERPRINT_FRAMES) break;
1015
+ }
1016
+ return frames;
1017
+ }
1018
+ function fingerprintError(name, message, stack) {
1019
+ const shape = message.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27}\b/gi, "#").replace(/\b[0-9a-f]{24}\b/gi, "#").replace(/\d+/g, "#");
1020
+ return createHash2("sha1").update([name, shape, ...topFrames(stack)].join("\n")).digest("hex").slice(0, 12);
1021
+ }
1022
+ var ErrorsProvider = class {
1023
+ id = "errors";
1024
+ groups = /* @__PURE__ */ new Map();
1025
+ total = 0;
1026
+ listener = null;
1027
+ /** Called with every newly recorded occurrence (persistence hook). */
1028
+ onRecord;
1029
+ /** Starts observing process-level failures. Idempotent. */
1030
+ attach() {
1031
+ if (this.listener) return;
1032
+ this.listener = (err, origin) => this.record(err, origin === "unhandledRejection" ? "rejection" : "uncaught");
1033
+ process.on("uncaughtExceptionMonitor", this.listener);
1034
+ }
1035
+ detach() {
1036
+ if (!this.listener) return;
1037
+ process.removeListener("uncaughtExceptionMonitor", this.listener);
1038
+ this.listener = null;
1039
+ }
1040
+ record(error, source, context = {}) {
1041
+ try {
1042
+ const { name, message, stack } = toError(error);
1043
+ const requestId = context.requestId ?? currentRequestId();
1044
+ const event = {
1045
+ name,
1046
+ message,
1047
+ stack: stack.slice(0, MAX_STACK_CHARS),
1048
+ source,
1049
+ at: Date.now(),
1050
+ ...requestId !== void 0 ? { requestId } : {},
1051
+ ...context.route ? { route: context.route } : {},
1052
+ ...context.status !== void 0 ? { status: context.status } : {}
1053
+ };
1054
+ this.apply(event);
1055
+ this.onRecord?.(event);
1056
+ } catch {
1057
+ }
1058
+ }
1059
+ /** Re-applies journaled occurrences without re-emitting them. */
1060
+ restore(events) {
1061
+ for (const event of events) this.apply(event);
1062
+ }
1063
+ apply(event) {
1064
+ const { name, message, stack, source, at: now, requestId } = event;
1065
+ const context = { route: event.route, status: event.status };
1066
+ const fingerprint = fingerprintError(name, message, stack);
1067
+ const existing = this.groups.get(fingerprint);
1068
+ this.total += 1;
1069
+ if (existing) {
1070
+ existing.count += 1;
1071
+ existing.lastSeenMs = now;
1072
+ existing.message = message.slice(0, MAX_MESSAGE_CHARS);
1073
+ existing.stack = stack.slice(0, MAX_STACK_CHARS);
1074
+ if (requestId !== void 0) existing.lastRequestId = requestId;
1075
+ if (context.route) existing.lastRoute = context.route;
1076
+ if (context.status !== void 0) existing.lastStatus = context.status;
1077
+ return;
1078
+ }
1079
+ if (this.groups.size >= MAX_GROUPS) {
1080
+ let oldest;
1081
+ for (const g of this.groups.values()) {
1082
+ if (!oldest || g.lastSeenMs < oldest.lastSeenMs) oldest = g;
1083
+ }
1084
+ if (oldest) this.groups.delete(oldest.id);
1085
+ }
1086
+ this.groups.set(fingerprint, {
1087
+ id: fingerprint,
1088
+ name,
1089
+ message: message.slice(0, MAX_MESSAGE_CHARS),
1090
+ stack: stack.slice(0, MAX_STACK_CHARS),
1091
+ source,
1092
+ count: 1,
1093
+ firstSeenMs: now,
1094
+ lastSeenMs: now,
1095
+ ...requestId !== void 0 ? { lastRequestId: requestId } : {},
1096
+ ...context.route ? { lastRoute: context.route } : {},
1097
+ ...context.status !== void 0 ? { lastStatus: context.status } : {}
1098
+ });
1099
+ }
1100
+ get() {
1101
+ const groups = [...this.groups.values()].sort((a, b) => b.lastSeenMs - a.lastSeenMs);
1102
+ return { ok: true, data: { total: this.total, groups } };
1103
+ }
1104
+ };
1105
+
690
1106
  // src/providers/outgoing.ts
691
1107
  import diagnosticsChannel from "diagnostics_channel";
692
1108
  import http from "http";
693
1109
  import https from "https";
694
1110
  import { syncBuiltinESMExports } from "module";
695
- var RESPONSE_PAGE_SIZE = 100;
696
- var recorders = /* @__PURE__ */ new Set();
1111
+ var RESPONSE_PAGE_SIZE2 = 100;
1112
+ var recorders2 = /* @__PURE__ */ new Set();
697
1113
  var originals = /* @__PURE__ */ new Map();
1114
+ var installed = /* @__PURE__ */ new Map();
698
1115
  var undiciUnsubscribe = null;
699
1116
  function describeTarget(defaultProtocol, args) {
700
1117
  try {
@@ -723,8 +1140,8 @@ function describeTarget(defaultProtocol, args) {
723
1140
  return null;
724
1141
  }
725
1142
  }
726
- function emit(entry) {
727
- for (const record of recorders) {
1143
+ function emit2(entry) {
1144
+ for (const record of recorders2) {
728
1145
  try {
729
1146
  record(entry);
730
1147
  } catch {
@@ -736,21 +1153,24 @@ function wrapModule(mod, protocol) {
736
1153
  const key = `${protocol}${name}`;
737
1154
  const original = mod[name];
738
1155
  originals.set(key, original);
1156
+ const state = { live: true };
739
1157
  const wrapped = function(...args) {
740
1158
  const req = original.apply(this, args);
741
- const target = recorders.size > 0 ? describeTarget(protocol, args) : null;
1159
+ const target = state.live && recorders2.size > 0 && !isSuppressed() ? describeTarget(protocol, args) : null;
742
1160
  if (target && req && typeof req.once === "function") {
743
1161
  const started = process.hrtime.bigint();
744
1162
  const timestampMs = Date.now();
1163
+ const requestId = currentRequestId();
745
1164
  let done = false;
746
1165
  const finish = (status, error) => {
747
1166
  if (done) return;
748
1167
  done = true;
749
- emit({
1168
+ emit2({
750
1169
  ...target,
751
1170
  status,
752
1171
  durationMs: Number(process.hrtime.bigint() - started) / 1e6,
753
1172
  timestampMs,
1173
+ ...requestId !== void 0 ? { requestId } : {},
754
1174
  ...error ? { error } : {}
755
1175
  });
756
1176
  };
@@ -763,15 +1183,21 @@ function wrapModule(mod, protocol) {
763
1183
  }
764
1184
  return req;
765
1185
  };
1186
+ installed.set(key, { fn: wrapped, state });
766
1187
  mod[name] = wrapped;
767
1188
  }
768
1189
  syncBuiltinESMExports();
769
1190
  }
770
1191
  function unwrapModule(mod, protocol) {
771
1192
  for (const name of ["request", "get"]) {
772
- const original = originals.get(`${protocol}${name}`);
773
- if (original) mod[name] = original;
774
- originals.delete(`${protocol}${name}`);
1193
+ const key = `${protocol}${name}`;
1194
+ const original = originals.get(key);
1195
+ const ours = installed.get(key);
1196
+ const target = mod;
1197
+ if (original && ours && target[name] === ours.fn) target[name] = original;
1198
+ if (ours) ours.state.live = false;
1199
+ originals.delete(key);
1200
+ installed.delete(key);
775
1201
  }
776
1202
  syncBuiltinESMExports();
777
1203
  }
@@ -784,7 +1210,13 @@ function subscribeUndici() {
784
1210
  });
785
1211
  const onCreate = (message) => {
786
1212
  const request = message.request;
787
- if (request) started.set(request, { at: process.hrtime.bigint(), ts: Date.now() });
1213
+ if (request && !isSuppressed()) {
1214
+ started.set(request, {
1215
+ at: process.hrtime.bigint(),
1216
+ ts: Date.now(),
1217
+ requestId: currentRequestId()
1218
+ });
1219
+ }
788
1220
  };
789
1221
  const onHeaders = (message) => {
790
1222
  const { request, response } = message;
@@ -797,11 +1229,12 @@ function subscribeUndici() {
797
1229
  const begin = started.get(request);
798
1230
  if (!begin) return;
799
1231
  started.delete(request);
800
- emit({
1232
+ emit2({
801
1233
  ...target(request),
802
1234
  status: statuses.get(request) ?? null,
803
1235
  durationMs: Number(process.hrtime.bigint() - begin.at) / 1e6,
804
1236
  timestampMs: begin.ts,
1237
+ ...begin.requestId !== void 0 ? { requestId: begin.requestId } : {},
805
1238
  ...error ? { error } : {}
806
1239
  });
807
1240
  };
@@ -822,8 +1255,8 @@ function subscribeUndici() {
822
1255
  };
823
1256
  }
824
1257
  function interceptOutgoing(record) {
825
- const first = recorders.size === 0;
826
- recorders.add(record);
1258
+ const first = recorders2.size === 0;
1259
+ recorders2.add(record);
827
1260
  if (first) {
828
1261
  wrapModule(http, "http:");
829
1262
  wrapModule(https, "https:");
@@ -834,7 +1267,7 @@ function interceptOutgoing(record) {
834
1267
  }
835
1268
  }
836
1269
  return () => {
837
- if (!recorders.delete(record) || recorders.size > 0) return;
1270
+ if (!recorders2.delete(record) || recorders2.size > 0) return;
838
1271
  unwrapModule(http, "http:");
839
1272
  unwrapModule(https, "https:");
840
1273
  undiciUnsubscribe?.();
@@ -847,6 +1280,8 @@ var OutgoingProvider = class {
847
1280
  nextId = 1;
848
1281
  failed = 0;
849
1282
  release = null;
1283
+ /** Called with every newly recorded entry (persistence hook). */
1284
+ onRecord;
850
1285
  constructor(size) {
851
1286
  this.buffer = new RingBuffer(size);
852
1287
  }
@@ -859,12 +1294,22 @@ var OutgoingProvider = class {
859
1294
  this.release = null;
860
1295
  }
861
1296
  record(entry) {
862
- this.buffer.push({ id: this.nextId, ...entry });
1297
+ const full = { id: this.nextId, ...entry };
1298
+ this.buffer.push(full);
863
1299
  this.nextId += 1;
864
1300
  if (entry.error || entry.status !== null && entry.status >= 500) this.failed += 1;
1301
+ this.onRecord?.(full);
1302
+ }
1303
+ /** Re-inserts journaled entries without re-emitting them. */
1304
+ restore(entries) {
1305
+ for (const entry of entries) {
1306
+ this.buffer.push(entry);
1307
+ this.nextId = Math.max(this.nextId, entry.id + 1);
1308
+ if (entry.error || entry.status !== null && entry.status >= 500) this.failed += 1;
1309
+ }
865
1310
  }
866
1311
  get() {
867
- const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE));
1312
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE2));
868
1313
  return { ok: true, data: { total: this.nextId - 1, failed: this.failed, entries } };
869
1314
  }
870
1315
  };
@@ -872,10 +1317,10 @@ var OutgoingProvider = class {
872
1317
  // src/providers/heap-snapshot.ts
873
1318
  import { chmod, mkdir, stat } from "fs/promises";
874
1319
  import { tmpdir } from "os";
875
- import { join as join2 } from "path";
1320
+ import { join as join3 } from "path";
876
1321
  import { writeHeapSnapshot } from "v8";
877
1322
  function defaultSnapshotDir() {
878
- return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? join2(tmpdir(), "nodeui-heap");
1323
+ return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? join3(tmpdir(), "nodeui-heap");
879
1324
  }
880
1325
  var HeapSnapshotProvider = class {
881
1326
  id = "heap-snapshot";
@@ -887,7 +1332,7 @@ var HeapSnapshotProvider = class {
887
1332
  const dir = ctx.config.heapSnapshotDir || defaultSnapshotDir();
888
1333
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
889
1334
  const fileName = `nodeui-heap-${process.pid}-${stamp}.heapsnapshot`;
890
- const filePath = join2(dir, fileName);
1335
+ const filePath = join3(dir, fileName);
891
1336
  try {
892
1337
  await mkdir(dir, { recursive: true, mode: 448 });
893
1338
  writeHeapSnapshot(filePath);
@@ -914,24 +1359,89 @@ var HeapSnapshotProvider = class {
914
1359
  };
915
1360
 
916
1361
  // src/providers/requests.ts
917
- var RESPONSE_PAGE_SIZE2 = 100;
1362
+ var RESPONSE_PAGE_SIZE3 = 100;
1363
+ var MAX_ROUTE_STATS = 8;
1364
+ function percentile(sorted, p) {
1365
+ if (sorted.length === 0) return 0;
1366
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
1367
+ return sorted[index] ?? 0;
1368
+ }
1369
+ function routeKey(path2) {
1370
+ return path2.split("/").map(
1371
+ (seg) => /^\d+$/.test(seg) || /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(seg) || /^[0-9a-f]{24}$/i.test(seg) ? ":id" : seg
1372
+ ).join("/");
1373
+ }
1374
+ function summarizeRequests(entries) {
1375
+ const durations = entries.map((e) => e.durationMs).sort((a, b) => a - b);
1376
+ const byStatus = { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0 };
1377
+ const groups = /* @__PURE__ */ new Map();
1378
+ for (const e of entries) {
1379
+ const bucket = `${Math.floor(e.status / 100)}xx`;
1380
+ if (bucket in byStatus) byStatus[bucket] += 1;
1381
+ const path2 = e.route ?? routeKey(e.path);
1382
+ const key = `${e.method} ${path2}`;
1383
+ const group = groups.get(key) ?? { method: e.method, path: path2, ms: [], errors: 0 };
1384
+ group.ms.push(e.durationMs);
1385
+ if (e.status >= 500) group.errors += 1;
1386
+ groups.set(key, group);
1387
+ }
1388
+ const routes = [...groups.values()].map((g) => {
1389
+ const sorted = [...g.ms].sort((a, b) => a - b);
1390
+ return {
1391
+ method: g.method,
1392
+ path: g.path,
1393
+ count: g.ms.length,
1394
+ avgMs: g.ms.reduce((a, b) => a + b, 0) / g.ms.length,
1395
+ p95Ms: percentile(sorted, 0.95),
1396
+ errors: g.errors
1397
+ };
1398
+ }).sort((a, b) => b.p95Ms - a.p95Ms).slice(0, MAX_ROUTE_STATS);
1399
+ return {
1400
+ count: entries.length,
1401
+ errors: byStatus["5xx"],
1402
+ errorRate: entries.length === 0 ? 0 : byStatus["5xx"] / entries.length,
1403
+ avgMs: durations.length === 0 ? 0 : durations.reduce((a, b) => a + b, 0) / durations.length,
1404
+ p50Ms: percentile(durations, 0.5),
1405
+ p95Ms: percentile(durations, 0.95),
1406
+ p99Ms: percentile(durations, 0.99),
1407
+ byStatus,
1408
+ routes
1409
+ };
1410
+ }
918
1411
  var RequestsProvider = class {
919
1412
  id = "requests";
920
1413
  buffer;
921
1414
  nextId = 1;
1415
+ /** Called with every newly recorded entry (persistence hook). */
1416
+ onRecord;
922
1417
  constructor(size) {
923
1418
  this.buffer = new RingBuffer(size);
924
1419
  }
925
- record(entry) {
926
- this.buffer.push({ id: this.nextId, ...entry });
1420
+ /** Reserves an id up front so in-flight work can be attributed to the request. */
1421
+ reserveId() {
1422
+ const id = this.nextId;
927
1423
  this.nextId += 1;
1424
+ return id;
1425
+ }
1426
+ record(entry, id = this.reserveId()) {
1427
+ const full = { id, ...entry };
1428
+ this.buffer.push(full);
1429
+ this.onRecord?.(full);
1430
+ }
1431
+ /** Re-inserts journaled entries without re-emitting them. */
1432
+ restore(entries) {
1433
+ for (const entry of entries) {
1434
+ this.buffer.push(entry);
1435
+ this.nextId = Math.max(this.nextId, entry.id + 1);
1436
+ }
928
1437
  }
929
1438
  get length() {
930
1439
  return this.buffer.length;
931
1440
  }
932
1441
  get() {
933
- const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE2));
934
- return { ok: true, data: { total: this.buffer.length, entries } };
1442
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE3));
1443
+ const summary = summarizeRequests(this.buffer.toArray());
1444
+ return { ok: true, data: { total: this.buffer.length, entries, summary } };
935
1445
  }
936
1446
  };
937
1447
 
@@ -1109,7 +1619,7 @@ function installWrappers() {
1109
1619
  const wrapper = (...args) => {
1110
1620
  for (const push of pushes) {
1111
1621
  try {
1112
- push({ level, message: args.map(formatArg).join(" ") });
1622
+ push({ level, message: args.map(formatArg).join(" "), requestId: currentRequestId() });
1113
1623
  } catch {
1114
1624
  }
1115
1625
  }
@@ -1153,8 +1663,13 @@ var LogsProvider = class {
1153
1663
  }
1154
1664
  start() {
1155
1665
  if (this.release) return;
1156
- this.release = interceptConsole(({ level, message }) => {
1157
- this.buffer.push({ level, message, timestamp: Date.now() });
1666
+ this.release = interceptConsole(({ level, message, requestId }) => {
1667
+ this.buffer.push({
1668
+ level,
1669
+ message,
1670
+ timestamp: Date.now(),
1671
+ ...requestId !== void 0 ? { requestId } : {}
1672
+ });
1158
1673
  });
1159
1674
  }
1160
1675
  stop() {
@@ -1178,6 +1693,160 @@ var LogsProvider = class {
1178
1693
  }
1179
1694
  };
1180
1695
 
1696
+ // src/request-detail.ts
1697
+ var DEFAULT_REQUEST_DETAIL = {
1698
+ query: true,
1699
+ headers: true,
1700
+ bodies: false,
1701
+ maxBodyBytes: 4096
1702
+ };
1703
+ function resolveRequestDetail(option, bodiesFromEnv) {
1704
+ if (option === false) {
1705
+ return { query: false, headers: false, bodies: false, maxBodyBytes: 0 };
1706
+ }
1707
+ const given = typeof option === "object" ? option : {};
1708
+ const maxBodyBytes = given.maxBodyBytes ?? DEFAULT_REQUEST_DETAIL.maxBodyBytes;
1709
+ if (!Number.isInteger(maxBodyBytes) || maxBodyBytes <= 0 || maxBodyBytes > 1048576) {
1710
+ throw new Error("captureRequestDetail.maxBodyBytes must be an integer in 1..1048576");
1711
+ }
1712
+ return {
1713
+ query: given.query ?? DEFAULT_REQUEST_DETAIL.query,
1714
+ headers: given.headers ?? DEFAULT_REQUEST_DETAIL.headers,
1715
+ bodies: given.bodies ?? (bodiesFromEnv || DEFAULT_REQUEST_DETAIL.bodies),
1716
+ maxBodyBytes
1717
+ };
1718
+ }
1719
+ var ALWAYS_REDACT = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key)$/i;
1720
+ var TEXTUAL_TYPE = /json|text|xml|x-www-form-urlencoded|javascript|graphql/i;
1721
+ function queryOf(req) {
1722
+ const raw = req.originalUrl ?? req.url ?? "";
1723
+ const index = raw.indexOf("?");
1724
+ if (index === -1) return void 0;
1725
+ const out = {};
1726
+ for (const [key, value] of new URLSearchParams(raw.slice(index + 1))) {
1727
+ out[key] = SECRET_KEY_PATTERN.test(key) ? SECRET_MASKED : value;
1728
+ }
1729
+ return Object.keys(out).length > 0 ? out : void 0;
1730
+ }
1731
+ function headersOf(req) {
1732
+ const out = {};
1733
+ for (const [name, value] of Object.entries(req.headers)) {
1734
+ if (value === void 0) continue;
1735
+ out[name] = ALWAYS_REDACT.test(name) || SECRET_KEY_PATTERN.test(name) ? SECRET_MASKED : Array.isArray(value) ? value.join(", ") : value;
1736
+ }
1737
+ return out;
1738
+ }
1739
+ function responseType(res) {
1740
+ const header = res.getHeader("content-type");
1741
+ if (header !== void 0) return String(header);
1742
+ const raw = res._header;
1743
+ const match = typeof raw === "string" ? /^content-type:[ \t]*(.*?)\r?$/im.exec(raw) : null;
1744
+ return match?.[1] ?? "";
1745
+ }
1746
+ var BodyCollector = class {
1747
+ constructor(max) {
1748
+ this.max = max;
1749
+ }
1750
+ max;
1751
+ chunks = [];
1752
+ size = 0;
1753
+ total = 0;
1754
+ add(chunk, encoding) {
1755
+ let buf;
1756
+ if (Buffer.isBuffer(chunk)) buf = chunk;
1757
+ else if (typeof chunk === "string") {
1758
+ buf = Buffer.from(
1759
+ chunk,
1760
+ typeof encoding === "string" ? encoding : "utf8"
1761
+ );
1762
+ } else if (chunk instanceof Uint8Array) buf = Buffer.from(chunk);
1763
+ else return;
1764
+ this.total += buf.length;
1765
+ if (this.size >= this.max) return;
1766
+ const slice = buf.subarray(0, this.max - this.size);
1767
+ this.chunks.push(slice);
1768
+ this.size += slice.length;
1769
+ }
1770
+ get truncated() {
1771
+ return this.total > this.size;
1772
+ }
1773
+ text() {
1774
+ return Buffer.concat(this.chunks).toString("utf8");
1775
+ }
1776
+ };
1777
+ function maskBody(text, contentType, mask, truncated) {
1778
+ if (!mask) return text;
1779
+ if (!truncated && /json/i.test(contentType)) {
1780
+ try {
1781
+ return JSON.stringify(maskSecrets(JSON.parse(text)));
1782
+ } catch {
1783
+ }
1784
+ }
1785
+ return maskSecretText(text);
1786
+ }
1787
+ function captureRequestDetail(req, res, settings, mask) {
1788
+ let reqBody = null;
1789
+ let resBody = null;
1790
+ if (settings.bodies) {
1791
+ const reqType = String(req.headers["content-type"] ?? "");
1792
+ if (TEXTUAL_TYPE.test(reqType)) {
1793
+ const collector2 = new BodyCollector(settings.maxBodyBytes);
1794
+ reqBody = collector2;
1795
+ const emit3 = req.emit.bind(req);
1796
+ req.emit = ((event, ...args) => {
1797
+ if (event === "data") {
1798
+ try {
1799
+ collector2.add(args[0]);
1800
+ } catch {
1801
+ }
1802
+ }
1803
+ return emit3(event, ...args);
1804
+ });
1805
+ }
1806
+ const collector = new BodyCollector(settings.maxBodyBytes);
1807
+ resBody = collector;
1808
+ const write = res.write.bind(res);
1809
+ const end = res.end.bind(res);
1810
+ const textual = () => TEXTUAL_TYPE.test(responseType(res));
1811
+ res.write = ((chunk, ...rest) => {
1812
+ try {
1813
+ if (textual()) collector.add(chunk, rest[0]);
1814
+ } catch {
1815
+ }
1816
+ return write(chunk, ...rest);
1817
+ });
1818
+ res.end = ((chunk, ...rest) => {
1819
+ try {
1820
+ if (chunk && typeof chunk !== "function" && textual()) collector.add(chunk, rest[0]);
1821
+ } catch {
1822
+ }
1823
+ return end(chunk, ...rest);
1824
+ });
1825
+ }
1826
+ return () => {
1827
+ const detail = {};
1828
+ if (settings.query) {
1829
+ const query = queryOf(req);
1830
+ if (query) detail.query = query;
1831
+ }
1832
+ if (settings.headers) detail.headers = headersOf(req);
1833
+ if (reqBody && reqBody.total > 0) {
1834
+ detail.requestBody = maskBody(
1835
+ reqBody.text(),
1836
+ String(req.headers["content-type"] ?? ""),
1837
+ mask,
1838
+ reqBody.truncated
1839
+ );
1840
+ if (reqBody.truncated) detail.requestBodyTruncated = true;
1841
+ }
1842
+ if (resBody && resBody.total > 0) {
1843
+ detail.responseBody = maskBody(resBody.text(), responseType(res), mask, resBody.truncated);
1844
+ if (resBody.truncated) detail.responseBodyTruncated = true;
1845
+ }
1846
+ return detail;
1847
+ };
1848
+ }
1849
+
1181
1850
  // src/sse.ts
1182
1851
  function startSse(res, extraHeaders = {}) {
1183
1852
  let closed = false;
@@ -1210,8 +1879,268 @@ function startSse(res, extraHeaders = {}) {
1210
1879
 
1211
1880
  // src/server.ts
1212
1881
  import { tmpdir as tmpdir2 } from "os";
1213
- import { join as join3, resolve as resolve2 } from "path";
1214
- var STATIC_ROOT = resolve2(__dirname, "..", "static");
1882
+ import { join as join4 } from "path";
1883
+
1884
+ // src/otlp.ts
1885
+ import { randomBytes as randomBytes2 } from "crypto";
1886
+ var KIND_SERVER = 2;
1887
+ var KIND_CLIENT = 3;
1888
+ var STATUS_ERROR = 2;
1889
+ var MAX_QUEUE = 1e3;
1890
+ var MAX_BATCH = 200;
1891
+ var MAX_TRACKED_REQUESTS = 1e3;
1892
+ var hex = (bytes) => randomBytes2(bytes).toString("hex");
1893
+ var nanos = (ms) => (BigInt(Math.round(ms * 1e3)) * 1000n).toString();
1894
+ var str = (key, value) => ({ key, value: { stringValue: value } });
1895
+ var int = (key, value) => ({
1896
+ key,
1897
+ value: { intValue: String(Math.round(value)) }
1898
+ });
1899
+ function tracesUrl(endpoint) {
1900
+ const trimmed = endpoint.replace(/\/+$/, "");
1901
+ return trimmed.endsWith("/v1/traces") ? trimmed : `${trimmed}/v1/traces`;
1902
+ }
1903
+ var OtlpExporter = class {
1904
+ constructor(options, env = process.env) {
1905
+ this.options = options;
1906
+ this.url = tracesUrl(options.endpoint);
1907
+ this.serviceName = options.serviceName ?? env.OTEL_SERVICE_NAME ?? "nodeui-app";
1908
+ }
1909
+ options;
1910
+ url;
1911
+ serviceName;
1912
+ queue = [];
1913
+ timer = null;
1914
+ contexts = /* @__PURE__ */ new Map();
1915
+ warned = false;
1916
+ exported = 0;
1917
+ dropped = 0;
1918
+ /** Stable ids for a request so children recorded earlier link to its span. */
1919
+ contextFor(requestId) {
1920
+ let ctx = this.contexts.get(requestId);
1921
+ if (!ctx) {
1922
+ ctx = { traceId: hex(16), spanId: hex(8) };
1923
+ this.contexts.set(requestId, ctx);
1924
+ if (this.contexts.size > MAX_TRACKED_REQUESTS) {
1925
+ const oldest = this.contexts.keys().next().value;
1926
+ if (oldest !== void 0) this.contexts.delete(oldest);
1927
+ }
1928
+ }
1929
+ return ctx;
1930
+ }
1931
+ request(entry) {
1932
+ const ctx = this.contextFor(entry.id);
1933
+ const attributes = [
1934
+ str("http.request.method", entry.method),
1935
+ str("url.path", maskSecretText(entry.path)),
1936
+ int("http.response.status_code", entry.status),
1937
+ str("client.address", entry.ip)
1938
+ ];
1939
+ if (entry.route) attributes.push(str("http.route", entry.route));
1940
+ this.push({
1941
+ ...ctx,
1942
+ name: `${entry.method} ${entry.route ?? entry.path}`,
1943
+ kind: KIND_SERVER,
1944
+ startTimeUnixNano: nanos(entry.timestampMs),
1945
+ endTimeUnixNano: nanos(entry.timestampMs + entry.durationMs),
1946
+ attributes,
1947
+ ...entry.status >= 500 ? { status: { code: STATUS_ERROR } } : {}
1948
+ });
1949
+ }
1950
+ outgoing(entry) {
1951
+ const attributes = [
1952
+ str("http.request.method", entry.method),
1953
+ str("url.full", maskSecretText(entry.url))
1954
+ ];
1955
+ if (entry.status !== null) attributes.push(int("http.response.status_code", entry.status));
1956
+ const failed = Boolean(entry.error) || entry.status !== null && entry.status >= 500;
1957
+ this.child(entry.requestId, {
1958
+ name: `${entry.method} ${entry.url}`,
1959
+ timestampMs: entry.timestampMs,
1960
+ durationMs: entry.durationMs,
1961
+ attributes,
1962
+ ...failed ? { status: { code: STATUS_ERROR, message: entry.error } } : {}
1963
+ });
1964
+ }
1965
+ query(entry) {
1966
+ const statement = maskSecretText(entry.sql);
1967
+ this.child(entry.requestId, {
1968
+ name: `${statement.split(/\s+/)[0]?.toUpperCase() ?? "QUERY"} ${entry.system}`,
1969
+ timestampMs: entry.timestampMs,
1970
+ durationMs: entry.durationMs,
1971
+ attributes: [str("db.system.name", entry.system), str("db.query.text", statement)],
1972
+ ...entry.error ? { status: { code: STATUS_ERROR, message: entry.error } } : {}
1973
+ });
1974
+ }
1975
+ child(requestId, span) {
1976
+ const parent = requestId === void 0 ? void 0 : this.contextFor(requestId);
1977
+ this.push({
1978
+ traceId: parent?.traceId ?? hex(16),
1979
+ spanId: hex(8),
1980
+ ...parent ? { parentSpanId: parent.spanId } : {},
1981
+ name: span.name,
1982
+ kind: KIND_CLIENT,
1983
+ startTimeUnixNano: nanos(span.timestampMs),
1984
+ endTimeUnixNano: nanos(span.timestampMs + span.durationMs),
1985
+ attributes: span.attributes,
1986
+ ...span.status ? { status: span.status } : {}
1987
+ });
1988
+ }
1989
+ push(span) {
1990
+ if (this.queue.length >= MAX_QUEUE) {
1991
+ this.queue.shift();
1992
+ this.dropped += 1;
1993
+ }
1994
+ this.queue.push(span);
1995
+ if (!this.timer) {
1996
+ this.timer = setTimeout(() => void this.flush(), this.options.intervalMs ?? 2e3);
1997
+ if (typeof this.timer.unref === "function") this.timer.unref();
1998
+ }
1999
+ }
2000
+ body(spans) {
2001
+ return JSON.stringify({
2002
+ resourceSpans: [
2003
+ {
2004
+ resource: { attributes: [str("service.name", this.serviceName)] },
2005
+ scopeSpans: [{ scope: { name: "@singhak/nodeui" }, spans }]
2006
+ }
2007
+ ]
2008
+ });
2009
+ }
2010
+ async flush() {
2011
+ this.timer = null;
2012
+ while (this.queue.length > 0) {
2013
+ const batch = this.queue.splice(0, MAX_BATCH);
2014
+ try {
2015
+ const res = await runSuppressed(
2016
+ () => fetch(this.url, {
2017
+ method: "POST",
2018
+ headers: { "content-type": "application/json", ...this.options.headers },
2019
+ body: this.body(batch),
2020
+ signal: AbortSignal.timeout(5e3)
2021
+ })
2022
+ );
2023
+ if (!res.ok) throw new Error(`collector answered ${res.status}`);
2024
+ this.exported += batch.length;
2025
+ } catch (err) {
2026
+ this.dropped += batch.length;
2027
+ if (!this.warned) {
2028
+ this.warned = true;
2029
+ console.warn(
2030
+ `[nodeui] OTLP export to ${this.url} failed (${err instanceof Error ? err.message : String(err)}); dropping spans.`
2031
+ );
2032
+ }
2033
+ return;
2034
+ }
2035
+ }
2036
+ }
2037
+ /** Cancels the timer and makes a best-effort final export. */
2038
+ close() {
2039
+ if (this.timer) clearTimeout(this.timer);
2040
+ this.timer = null;
2041
+ void this.flush();
2042
+ }
2043
+ };
2044
+
2045
+ // src/persistence.ts
2046
+ import {
2047
+ appendFile,
2048
+ appendFileSync,
2049
+ existsSync as existsSync2,
2050
+ mkdirSync,
2051
+ readFileSync,
2052
+ renameSync,
2053
+ statSync as statSync2
2054
+ } from "fs";
2055
+ import { dirname } from "path";
2056
+ var DEFAULT_PERSIST_MAX_BYTES = 5 * 1024 * 1024;
2057
+ var FLUSH_DELAY_MS = 250;
2058
+ var Persistence = class {
2059
+ constructor(file, maxBytes = DEFAULT_PERSIST_MAX_BYTES) {
2060
+ this.file = file;
2061
+ this.maxBytes = maxBytes;
2062
+ try {
2063
+ mkdirSync(dirname(file), { recursive: true });
2064
+ } catch {
2065
+ }
2066
+ }
2067
+ file;
2068
+ maxBytes;
2069
+ pending = [];
2070
+ timer = null;
2071
+ writing = false;
2072
+ append(kind, data) {
2073
+ try {
2074
+ this.pending.push(`${JSON.stringify({ kind, data })}
2075
+ `);
2076
+ } catch {
2077
+ return;
2078
+ }
2079
+ if (!this.timer) {
2080
+ this.timer = setTimeout(() => this.flush(), FLUSH_DELAY_MS);
2081
+ if (typeof this.timer.unref === "function") this.timer.unref();
2082
+ }
2083
+ }
2084
+ /** Reads the journal (rotated file first) in chronological order. */
2085
+ load() {
2086
+ const out = [];
2087
+ for (const path2 of [`${this.file}.1`, this.file]) {
2088
+ let text;
2089
+ try {
2090
+ text = readFileSync(path2, "utf8");
2091
+ } catch {
2092
+ continue;
2093
+ }
2094
+ for (const line of text.split("\n")) {
2095
+ if (!line) continue;
2096
+ try {
2097
+ const parsed = JSON.parse(line);
2098
+ if (parsed && typeof parsed.kind === "string") out.push(parsed);
2099
+ } catch {
2100
+ }
2101
+ }
2102
+ }
2103
+ return out;
2104
+ }
2105
+ rotateIfNeeded() {
2106
+ try {
2107
+ if (existsSync2(this.file) && statSync2(this.file).size >= this.maxBytes) {
2108
+ renameSync(this.file, `${this.file}.1`);
2109
+ }
2110
+ } catch {
2111
+ }
2112
+ }
2113
+ flush() {
2114
+ this.timer = null;
2115
+ if (this.writing || this.pending.length === 0) {
2116
+ if (this.pending.length > 0) this.timer = setTimeout(() => this.flush(), FLUSH_DELAY_MS);
2117
+ return;
2118
+ }
2119
+ this.rotateIfNeeded();
2120
+ const chunk = this.pending.join("");
2121
+ this.pending = [];
2122
+ this.writing = true;
2123
+ appendFile(this.file, chunk, { mode: 384 }, () => {
2124
+ this.writing = false;
2125
+ });
2126
+ }
2127
+ /** Writes anything still buffered, synchronously (used on shutdown). */
2128
+ close() {
2129
+ if (this.timer) {
2130
+ clearTimeout(this.timer);
2131
+ this.timer = null;
2132
+ }
2133
+ if (this.pending.length === 0) return;
2134
+ try {
2135
+ this.rotateIfNeeded();
2136
+ appendFileSync(this.file, this.pending.join(""), { mode: 384 });
2137
+ } catch {
2138
+ }
2139
+ this.pending = [];
2140
+ }
2141
+ };
2142
+
2143
+ // src/server.ts
1215
2144
  var SSE_HEARTBEAT_MS = 15e3;
1216
2145
  function serializeEnvelope(envelope, mask = true) {
1217
2146
  return JSON.stringify(mask ? maskSecrets(envelope) : envelope);
@@ -1228,7 +2157,9 @@ var BUILT_IN_PANELS = /* @__PURE__ */ new Set([
1228
2157
  "routes",
1229
2158
  "logs",
1230
2159
  "metrics",
1231
- "outgoing"
2160
+ "outgoing",
2161
+ "errors",
2162
+ "queries"
1232
2163
  ]);
1233
2164
  var PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1234
2165
  var RESERVED_API_NAMES = /* @__PURE__ */ new Set(["config", "live", "confirmations"]);
@@ -1243,6 +2174,13 @@ function listFrom(value, raw) {
1243
2174
  if (value) return value;
1244
2175
  return (raw ?? "").split(",").map((v) => v.trim()).filter(Boolean);
1245
2176
  }
2177
+ function otlpHostname(endpoint) {
2178
+ try {
2179
+ return new URL(endpoint).hostname.replace(/^\[|\]$/g, "");
2180
+ } catch {
2181
+ throw new Error(`nodeui otlp endpoint "${endpoint}" is not a valid URL`);
2182
+ }
2183
+ }
1246
2184
  function pathnameOf(req) {
1247
2185
  const raw = req.originalUrl ?? req.url ?? "/";
1248
2186
  try {
@@ -1256,6 +2194,13 @@ function normalizePath(value) {
1256
2194
  if (!trimmed.startsWith("/")) throw new Error(`NODEUI_PATH must start with "/", got "${value}"`);
1257
2195
  return trimmed.replace(/\/+$/, "") || "/";
1258
2196
  }
2197
+ function resolveCapture(option, env) {
2198
+ const value = option ?? env ?? "always";
2199
+ if (value !== "always" && value !== "lazy") {
2200
+ throw new Error(`nodeui capture must be 'always' or 'lazy', got "${String(value)}"`);
2201
+ }
2202
+ return value;
2203
+ }
1259
2204
  function positiveInt(value, fallback, name) {
1260
2205
  const n = value === void 0 ? fallback : value;
1261
2206
  if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} must be a positive integer`);
@@ -1291,6 +2236,18 @@ function createNodeUI(options = {}) {
1291
2236
  enabled: activation.active,
1292
2237
  activationReason: activation.reason,
1293
2238
  maskSecrets: options.maskSecrets ?? true,
2239
+ capture: resolveCapture(options.capture, env.NODEUI_CAPTURE),
2240
+ persistFile: (typeof options.persist === "string" ? options.persist : options.persist?.file) ?? env.NODEUI_PERSIST_FILE ?? null,
2241
+ persistMaxBytes: positiveInt(
2242
+ typeof options.persist === "object" ? options.persist.maxBytes : void 0,
2243
+ DEFAULT_PERSIST_MAX_BYTES,
2244
+ "persist.maxBytes"
2245
+ ),
2246
+ otlp: typeof options.otlp === "string" ? { endpoint: options.otlp } : options.otlp ?? (env.NODEUI_OTLP_ENDPOINT ? { endpoint: env.NODEUI_OTLP_ENDPOINT } : null),
2247
+ requestDetail: resolveRequestDetail(
2248
+ options.captureRequestDetail,
2249
+ env.NODEUI_CAPTURE_BODIES === "true"
2250
+ ),
1294
2251
  inactivityTimeoutMs: positiveInt(
1295
2252
  options.inactivityTimeoutMs,
1296
2253
  env.NODEUI_INACTIVITY_TIMEOUT_MS ? Number(env.NODEUI_INACTIVITY_TIMEOUT_MS) : 6e4,
@@ -1301,7 +2258,7 @@ function createNodeUI(options = {}) {
1301
2258
  env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
1302
2259
  "NODEUI_CONFIRM_TTL_MS"
1303
2260
  ),
1304
- heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? join3(tmpdir2(), "nodeui-heap"),
2261
+ heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? join4(tmpdir2(), "nodeui-heap"),
1305
2262
  allowedHosts: listFrom(options.allowedHosts, env.NODEUI_ALLOWED_HOSTS),
1306
2263
  allowedOrigins: listFrom(options.allowedOrigins, env.NODEUI_ALLOWED_ORIGINS),
1307
2264
  allowedRemoteAddresses: listFrom(options.allowedRemoteAddresses, env.NODEUI_ALLOWED_REMOTE),
@@ -1332,6 +2289,15 @@ function createNodeUI(options = {}) {
1332
2289
  "[nodeui] running with NODE_ENV=production. The console exposes environment, logs and heap data; keep it behind loopback and an auth token."
1333
2290
  );
1334
2291
  }
2292
+ if (config.enabled) {
2293
+ for (const entry of config.allowedRemoteAddresses) {
2294
+ if (!isValidAddressEntry(entry)) {
2295
+ console.warn(
2296
+ `[nodeui] ignoring allowedRemoteAddresses entry "${entry}": expected an IP or a CIDR such as 172.16.0.0/12 or fd00::/8; it will never match.`
2297
+ );
2298
+ }
2299
+ }
2300
+ }
1335
2301
  if (config.enabled && !config.maskSecrets) {
1336
2302
  console.warn("[nodeui] secret masking is disabled; panels may show credentials.");
1337
2303
  }
@@ -1362,9 +2328,59 @@ function createNodeUI(options = {}) {
1362
2328
  registry.register(envProvider);
1363
2329
  const routesProvider = new RoutesProvider();
1364
2330
  registry.register(routesProvider);
1365
- registry.register(
1366
- new OutgoingProvider(positiveInt(options.outgoingLogSize, 200, "outgoingLogSize"))
2331
+ const outgoingProvider = new OutgoingProvider(
2332
+ positiveInt(options.outgoingLogSize, 200, "outgoingLogSize")
1367
2333
  );
2334
+ registry.register(outgoingProvider);
2335
+ const queriesProvider = new QueriesProvider({
2336
+ size: positiveInt(options.queryLogSize, 200, "queryLogSize"),
2337
+ slowQueryMs: positiveInt(options.slowQueryMs, 100, "slowQueryMs")
2338
+ });
2339
+ registry.register(queriesProvider);
2340
+ const errorsProvider = new ErrorsProvider();
2341
+ registry.register(errorsProvider);
2342
+ if (config.enabled) errorsProvider.attach();
2343
+ const persistence = config.enabled && config.persistFile ? new Persistence(config.persistFile, config.persistMaxBytes) : null;
2344
+ const exporter = config.enabled && config.otlp ? new OtlpExporter(config.otlp) : null;
2345
+ const pinned = /* @__PURE__ */ new Set();
2346
+ const otlpHost = config.otlp ? otlpHostname(config.otlp.endpoint) : null;
2347
+ if (exporter && config.otlp && !isLoopbackHostname(otlpHost)) {
2348
+ console.warn(
2349
+ `[nodeui] exporting telemetry to non-local OTLP endpoint ${config.otlp.endpoint}; requests, queries and outgoing URLs leave this machine (masked).`
2350
+ );
2351
+ }
2352
+ if (persistence) {
2353
+ const byKind = (kind) => persistence.load().filter((r) => r.kind === kind).map((r) => r.data);
2354
+ requestsProvider.restore(byKind("request"));
2355
+ outgoingProvider.restore(byKind("outgoing"));
2356
+ queriesProvider.restore(byKind("query"));
2357
+ errorsProvider.restore(byKind("error"));
2358
+ }
2359
+ if (persistence || exporter) {
2360
+ const save = (kind, data) => persistence?.append(kind, config.maskSecrets ? maskSecrets(data) : data);
2361
+ requestsProvider.onRecord = (e) => {
2362
+ save("request", e);
2363
+ exporter?.request(e);
2364
+ };
2365
+ outgoingProvider.onRecord = (e) => {
2366
+ save("outgoing", e);
2367
+ exporter?.outgoing(e);
2368
+ };
2369
+ queriesProvider.onRecord = (e) => {
2370
+ save("query", e);
2371
+ exporter?.query(e);
2372
+ };
2373
+ errorsProvider.onRecord = (e) => save("error", e);
2374
+ }
2375
+ const pinnedProviders = [];
2376
+ if (persistence || exporter || config.enabled && config.capture === "always") {
2377
+ pinnedProviders.push(outgoingProvider, queriesProvider);
2378
+ }
2379
+ if (config.enabled && config.capture === "always") pinnedProviders.push(logsProvider);
2380
+ for (const provider of pinnedProviders) {
2381
+ provider.start?.(ctx);
2382
+ pinned.add(provider.id);
2383
+ }
1368
2384
  const pluginMeta = [];
1369
2385
  for (const plugin of options.plugins ?? []) {
1370
2386
  if (!PLUGIN_ID_PATTERN.test(plugin.id) || BUILT_IN_PANELS.has(plugin.id) || RESERVED_API_NAMES.has(plugin.id) || registry.get(plugin.id)) {
@@ -1381,7 +2397,7 @@ function createNodeUI(options = {}) {
1381
2397
  let sseClients = 0;
1382
2398
  function stopAll() {
1383
2399
  for (const id of activeProviders) {
1384
- registry.get(id)?.stop?.(ctx);
2400
+ if (!pinned.has(id)) registry.get(id)?.stop?.(ctx);
1385
2401
  }
1386
2402
  activeProviders.clear();
1387
2403
  if (inactivityTimer) {
@@ -1453,7 +2469,7 @@ function createNodeUI(options = {}) {
1453
2469
  pollIntervalMs: config.pollIntervalMs,
1454
2470
  panels: registry.ids(),
1455
2471
  masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source },
1456
- authRequired: config.authRequired,
2472
+ locked: config.authRequired,
1457
2473
  plugins: pluginMeta
1458
2474
  };
1459
2475
  }
@@ -1553,7 +2569,7 @@ function createNodeUI(options = {}) {
1553
2569
  }
1554
2570
  function serveStatic(req, res, urlPath) {
1555
2571
  const relative = urlPath.slice(path2.length);
1556
- const asset = resolveStaticAsset(STATIC_ROOT, relative);
2572
+ const asset = resolveStaticAsset(uiAssetsDir(), relative);
1557
2573
  if (!asset) {
1558
2574
  sendJson(res, 404, { ok: false, error: { code: "not-found", message: "Not found" } });
1559
2575
  return;
@@ -1614,21 +2630,37 @@ function createNodeUI(options = {}) {
1614
2630
  ctx.store["express-router"] = router;
1615
2631
  }
1616
2632
  }
2633
+ function routePatternOf(req) {
2634
+ const r = req;
2635
+ if (typeof r.nodeuiRoute === "string") return r.nodeuiRoute;
2636
+ const path3 = r.route?.path;
2637
+ if (typeof path3 !== "string") return void 0;
2638
+ return `${typeof r.baseUrl === "string" ? r.baseUrl : ""}${path3}` || void 0;
2639
+ }
1617
2640
  function recordAppRequest(req, res) {
1618
2641
  const started = process.hrtime.bigint();
1619
2642
  const timestampMs = Date.now();
2643
+ const requestId = requestsProvider.reserveId();
2644
+ bindRequestId(req, requestId);
2645
+ const collectDetail = captureRequestDetail(req, res, config.requestDetail, config.maskSecrets);
1620
2646
  res.on("finish", () => {
1621
2647
  const durationMs = Number(process.hrtime.bigint() - started) / 1e6;
1622
- requestsProvider.record({
1623
- method: req.method ?? "?",
1624
- path: pathnameOf(req),
1625
- status: res.statusCode,
1626
- durationMs,
1627
- timestampMs,
1628
- ip: req.socket.remoteAddress ?? "unknown"
1629
- });
2648
+ requestsProvider.record(
2649
+ {
2650
+ method: req.method ?? "?",
2651
+ path: pathnameOf(req),
2652
+ status: res.statusCode,
2653
+ durationMs,
2654
+ timestampMs,
2655
+ ip: req.socket.remoteAddress ?? "unknown",
2656
+ route: routePatternOf(req),
2657
+ ...collectDetail()
2658
+ },
2659
+ requestId
2660
+ );
1630
2661
  metricsProvider.record(res.statusCode);
1631
2662
  });
2663
+ return requestId;
1632
2664
  }
1633
2665
  const server = {
1634
2666
  get config() {
@@ -1652,8 +2684,8 @@ function createNodeUI(options = {}) {
1652
2684
  void handleNodeUIPath(req, res, urlPath);
1653
2685
  return;
1654
2686
  }
1655
- recordAppRequest(req, res);
1656
- next();
2687
+ const requestId = recordAppRequest(req, res);
2688
+ runWithRequestId(requestId, next);
1657
2689
  };
1658
2690
  },
1659
2691
  async handle(req, res) {
@@ -1681,7 +2713,35 @@ function createNodeUI(options = {}) {
1681
2713
  addLogSource(entry) {
1682
2714
  logsProvider.addSource(entry);
1683
2715
  },
2716
+ recordQuery(query) {
2717
+ if (!config.enabled) return;
2718
+ queriesProvider.record({
2719
+ ...query,
2720
+ sql: query.sql.slice(0, 2e3),
2721
+ timestampMs: Date.now() - query.durationMs,
2722
+ requestId: currentRequestId()
2723
+ });
2724
+ },
2725
+ trackPrisma(client) {
2726
+ if (!config.enabled) return;
2727
+ client.$on(
2728
+ "query",
2729
+ (e) => server.recordQuery({ system: "prisma", sql: e.query, durationMs: Number(e.duration) })
2730
+ );
2731
+ },
2732
+ recordError(error, context) {
2733
+ if (!config.enabled) return;
2734
+ const e = error;
2735
+ const status = e?.status ?? e?.statusCode;
2736
+ if (typeof status === "number" && status >= 400 && status < 500) return;
2737
+ errorsProvider.record(error, "request", context);
2738
+ },
1684
2739
  shutdown() {
2740
+ errorsProvider.detach();
2741
+ persistence?.close();
2742
+ exporter?.close();
2743
+ for (const id of pinned) registry.get(id)?.stop?.(ctx);
2744
+ pinned.clear();
1685
2745
  stopAll();
1686
2746
  }
1687
2747
  };
@@ -1692,15 +2752,19 @@ export {
1692
2752
  ConfirmationStore,
1693
2753
  CpuProvider,
1694
2754
  DEFAULT_LOG_SIZE,
2755
+ DEFAULT_REQUEST_DETAIL,
1695
2756
  EnvProvider,
2757
+ ErrorsProvider,
1696
2758
  EventLoopLagProvider,
1697
2759
  HealthProvider,
1698
2760
  HeapSnapshotProvider,
1699
2761
  LogsProvider,
1700
2762
  MemoryProvider,
1701
2763
  MetricsProvider,
2764
+ N_PLUS_ONE_THRESHOLD,
1702
2765
  OutgoingProvider,
1703
2766
  ProviderRegistry,
2767
+ QueriesProvider,
1704
2768
  RequestsProvider,
1705
2769
  RingBuffer,
1706
2770
  RoutesProvider,
@@ -1710,18 +2774,27 @@ export {
1710
2774
  StartupTracker,
1711
2775
  createGuard,
1712
2776
  createNodeUI,
2777
+ currentRequestId,
2778
+ enterRequestContext,
1713
2779
  extractRoutes,
2780
+ fingerprintError,
1714
2781
  hostnameFromHostHeader,
1715
2782
  interceptConsole,
1716
2783
  interceptOutgoing,
2784
+ interceptQueries,
1717
2785
  isLoopbackAddress,
1718
2786
  isLoopbackHostname,
2787
+ isValidAddressEntry,
1719
2788
  maskSecretText,
1720
2789
  maskSecrets,
1721
2790
  matchesAddress,
2791
+ normalizeSql,
1722
2792
  resolveActivation,
1723
2793
  resolveStaticAsset,
2794
+ runInRequestContext,
1724
2795
  serializeEnvelope,
1725
- startSse
2796
+ startSse,
2797
+ summarizeRequests,
2798
+ uiAssetsDir
1726
2799
  };
1727
2800
  //# sourceMappingURL=index.mjs.map