@singhak/nodeui-core 0.3.1 → 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,7 +1359,7 @@ 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;
918
1363
  var MAX_ROUTE_STATS = 8;
919
1364
  function percentile(sorted, p) {
920
1365
  if (sorted.length === 0) return 0;
@@ -933,7 +1378,7 @@ function summarizeRequests(entries) {
933
1378
  for (const e of entries) {
934
1379
  const bucket = `${Math.floor(e.status / 100)}xx`;
935
1380
  if (bucket in byStatus) byStatus[bucket] += 1;
936
- const path2 = routeKey(e.path);
1381
+ const path2 = e.route ?? routeKey(e.path);
937
1382
  const key = `${e.method} ${path2}`;
938
1383
  const group = groups.get(key) ?? { method: e.method, path: path2, ms: [], errors: 0 };
939
1384
  group.ms.push(e.durationMs);
@@ -967,18 +1412,34 @@ var RequestsProvider = class {
967
1412
  id = "requests";
968
1413
  buffer;
969
1414
  nextId = 1;
1415
+ /** Called with every newly recorded entry (persistence hook). */
1416
+ onRecord;
970
1417
  constructor(size) {
971
1418
  this.buffer = new RingBuffer(size);
972
1419
  }
973
- record(entry) {
974
- 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;
975
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
+ }
976
1437
  }
977
1438
  get length() {
978
1439
  return this.buffer.length;
979
1440
  }
980
1441
  get() {
981
- const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE2));
1442
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE3));
982
1443
  const summary = summarizeRequests(this.buffer.toArray());
983
1444
  return { ok: true, data: { total: this.buffer.length, entries, summary } };
984
1445
  }
@@ -1158,7 +1619,7 @@ function installWrappers() {
1158
1619
  const wrapper = (...args) => {
1159
1620
  for (const push of pushes) {
1160
1621
  try {
1161
- push({ level, message: args.map(formatArg).join(" ") });
1622
+ push({ level, message: args.map(formatArg).join(" "), requestId: currentRequestId() });
1162
1623
  } catch {
1163
1624
  }
1164
1625
  }
@@ -1202,8 +1663,13 @@ var LogsProvider = class {
1202
1663
  }
1203
1664
  start() {
1204
1665
  if (this.release) return;
1205
- this.release = interceptConsole(({ level, message }) => {
1206
- 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
+ });
1207
1673
  });
1208
1674
  }
1209
1675
  stop() {
@@ -1227,6 +1693,160 @@ var LogsProvider = class {
1227
1693
  }
1228
1694
  };
1229
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
+
1230
1850
  // src/sse.ts
1231
1851
  function startSse(res, extraHeaders = {}) {
1232
1852
  let closed = false;
@@ -1259,8 +1879,268 @@ function startSse(res, extraHeaders = {}) {
1259
1879
 
1260
1880
  // src/server.ts
1261
1881
  import { tmpdir as tmpdir2 } from "os";
1262
- import { join as join3, resolve as resolve2 } from "path";
1263
- 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
1264
2144
  var SSE_HEARTBEAT_MS = 15e3;
1265
2145
  function serializeEnvelope(envelope, mask = true) {
1266
2146
  return JSON.stringify(mask ? maskSecrets(envelope) : envelope);
@@ -1277,7 +2157,9 @@ var BUILT_IN_PANELS = /* @__PURE__ */ new Set([
1277
2157
  "routes",
1278
2158
  "logs",
1279
2159
  "metrics",
1280
- "outgoing"
2160
+ "outgoing",
2161
+ "errors",
2162
+ "queries"
1281
2163
  ]);
1282
2164
  var PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1283
2165
  var RESERVED_API_NAMES = /* @__PURE__ */ new Set(["config", "live", "confirmations"]);
@@ -1292,6 +2174,13 @@ function listFrom(value, raw) {
1292
2174
  if (value) return value;
1293
2175
  return (raw ?? "").split(",").map((v) => v.trim()).filter(Boolean);
1294
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
+ }
1295
2184
  function pathnameOf(req) {
1296
2185
  const raw = req.originalUrl ?? req.url ?? "/";
1297
2186
  try {
@@ -1305,6 +2194,13 @@ function normalizePath(value) {
1305
2194
  if (!trimmed.startsWith("/")) throw new Error(`NODEUI_PATH must start with "/", got "${value}"`);
1306
2195
  return trimmed.replace(/\/+$/, "") || "/";
1307
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
+ }
1308
2204
  function positiveInt(value, fallback, name) {
1309
2205
  const n = value === void 0 ? fallback : value;
1310
2206
  if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} must be a positive integer`);
@@ -1340,6 +2236,18 @@ function createNodeUI(options = {}) {
1340
2236
  enabled: activation.active,
1341
2237
  activationReason: activation.reason,
1342
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
+ ),
1343
2251
  inactivityTimeoutMs: positiveInt(
1344
2252
  options.inactivityTimeoutMs,
1345
2253
  env.NODEUI_INACTIVITY_TIMEOUT_MS ? Number(env.NODEUI_INACTIVITY_TIMEOUT_MS) : 6e4,
@@ -1350,7 +2258,7 @@ function createNodeUI(options = {}) {
1350
2258
  env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
1351
2259
  "NODEUI_CONFIRM_TTL_MS"
1352
2260
  ),
1353
- 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"),
1354
2262
  allowedHosts: listFrom(options.allowedHosts, env.NODEUI_ALLOWED_HOSTS),
1355
2263
  allowedOrigins: listFrom(options.allowedOrigins, env.NODEUI_ALLOWED_ORIGINS),
1356
2264
  allowedRemoteAddresses: listFrom(options.allowedRemoteAddresses, env.NODEUI_ALLOWED_REMOTE),
@@ -1381,6 +2289,15 @@ function createNodeUI(options = {}) {
1381
2289
  "[nodeui] running with NODE_ENV=production. The console exposes environment, logs and heap data; keep it behind loopback and an auth token."
1382
2290
  );
1383
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
+ }
1384
2301
  if (config.enabled && !config.maskSecrets) {
1385
2302
  console.warn("[nodeui] secret masking is disabled; panels may show credentials.");
1386
2303
  }
@@ -1411,9 +2328,59 @@ function createNodeUI(options = {}) {
1411
2328
  registry.register(envProvider);
1412
2329
  const routesProvider = new RoutesProvider();
1413
2330
  registry.register(routesProvider);
1414
- registry.register(
1415
- new OutgoingProvider(positiveInt(options.outgoingLogSize, 200, "outgoingLogSize"))
2331
+ const outgoingProvider = new OutgoingProvider(
2332
+ positiveInt(options.outgoingLogSize, 200, "outgoingLogSize")
1416
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
+ }
1417
2384
  const pluginMeta = [];
1418
2385
  for (const plugin of options.plugins ?? []) {
1419
2386
  if (!PLUGIN_ID_PATTERN.test(plugin.id) || BUILT_IN_PANELS.has(plugin.id) || RESERVED_API_NAMES.has(plugin.id) || registry.get(plugin.id)) {
@@ -1430,7 +2397,7 @@ function createNodeUI(options = {}) {
1430
2397
  let sseClients = 0;
1431
2398
  function stopAll() {
1432
2399
  for (const id of activeProviders) {
1433
- registry.get(id)?.stop?.(ctx);
2400
+ if (!pinned.has(id)) registry.get(id)?.stop?.(ctx);
1434
2401
  }
1435
2402
  activeProviders.clear();
1436
2403
  if (inactivityTimer) {
@@ -1602,7 +2569,7 @@ function createNodeUI(options = {}) {
1602
2569
  }
1603
2570
  function serveStatic(req, res, urlPath) {
1604
2571
  const relative = urlPath.slice(path2.length);
1605
- const asset = resolveStaticAsset(STATIC_ROOT, relative);
2572
+ const asset = resolveStaticAsset(uiAssetsDir(), relative);
1606
2573
  if (!asset) {
1607
2574
  sendJson(res, 404, { ok: false, error: { code: "not-found", message: "Not found" } });
1608
2575
  return;
@@ -1663,21 +2630,37 @@ function createNodeUI(options = {}) {
1663
2630
  ctx.store["express-router"] = router;
1664
2631
  }
1665
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
+ }
1666
2640
  function recordAppRequest(req, res) {
1667
2641
  const started = process.hrtime.bigint();
1668
2642
  const timestampMs = Date.now();
2643
+ const requestId = requestsProvider.reserveId();
2644
+ bindRequestId(req, requestId);
2645
+ const collectDetail = captureRequestDetail(req, res, config.requestDetail, config.maskSecrets);
1669
2646
  res.on("finish", () => {
1670
2647
  const durationMs = Number(process.hrtime.bigint() - started) / 1e6;
1671
- requestsProvider.record({
1672
- method: req.method ?? "?",
1673
- path: pathnameOf(req),
1674
- status: res.statusCode,
1675
- durationMs,
1676
- timestampMs,
1677
- ip: req.socket.remoteAddress ?? "unknown"
1678
- });
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
+ );
1679
2661
  metricsProvider.record(res.statusCode);
1680
2662
  });
2663
+ return requestId;
1681
2664
  }
1682
2665
  const server = {
1683
2666
  get config() {
@@ -1701,8 +2684,8 @@ function createNodeUI(options = {}) {
1701
2684
  void handleNodeUIPath(req, res, urlPath);
1702
2685
  return;
1703
2686
  }
1704
- recordAppRequest(req, res);
1705
- next();
2687
+ const requestId = recordAppRequest(req, res);
2688
+ runWithRequestId(requestId, next);
1706
2689
  };
1707
2690
  },
1708
2691
  async handle(req, res) {
@@ -1730,7 +2713,35 @@ function createNodeUI(options = {}) {
1730
2713
  addLogSource(entry) {
1731
2714
  logsProvider.addSource(entry);
1732
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
+ },
1733
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();
1734
2745
  stopAll();
1735
2746
  }
1736
2747
  };
@@ -1741,15 +2752,19 @@ export {
1741
2752
  ConfirmationStore,
1742
2753
  CpuProvider,
1743
2754
  DEFAULT_LOG_SIZE,
2755
+ DEFAULT_REQUEST_DETAIL,
1744
2756
  EnvProvider,
2757
+ ErrorsProvider,
1745
2758
  EventLoopLagProvider,
1746
2759
  HealthProvider,
1747
2760
  HeapSnapshotProvider,
1748
2761
  LogsProvider,
1749
2762
  MemoryProvider,
1750
2763
  MetricsProvider,
2764
+ N_PLUS_ONE_THRESHOLD,
1751
2765
  OutgoingProvider,
1752
2766
  ProviderRegistry,
2767
+ QueriesProvider,
1753
2768
  RequestsProvider,
1754
2769
  RingBuffer,
1755
2770
  RoutesProvider,
@@ -1759,19 +2774,27 @@ export {
1759
2774
  StartupTracker,
1760
2775
  createGuard,
1761
2776
  createNodeUI,
2777
+ currentRequestId,
2778
+ enterRequestContext,
1762
2779
  extractRoutes,
2780
+ fingerprintError,
1763
2781
  hostnameFromHostHeader,
1764
2782
  interceptConsole,
1765
2783
  interceptOutgoing,
2784
+ interceptQueries,
1766
2785
  isLoopbackAddress,
1767
2786
  isLoopbackHostname,
2787
+ isValidAddressEntry,
1768
2788
  maskSecretText,
1769
2789
  maskSecrets,
1770
2790
  matchesAddress,
2791
+ normalizeSql,
1771
2792
  resolveActivation,
1772
2793
  resolveStaticAsset,
2794
+ runInRequestContext,
1773
2795
  serializeEnvelope,
1774
2796
  startSse,
1775
- summarizeRequests
2797
+ summarizeRequests,
2798
+ uiAssetsDir
1776
2799
  };
1777
2800
  //# sourceMappingURL=index.mjs.map