@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/README.md +6 -4
- package/dist/index.cjs +1141 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +313 -6
- package/dist/index.d.ts +313 -6
- package/dist/index.mjs +1131 -58
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
- package/static/assets/index-BrEFUVm9.css +1 -0
- package/static/assets/index-CH4-6sZB.js +12 -0
- package/static/index.html +3 -2
- package/static/assets/index-CJCpPtrr.js +0 -11
- package/static/assets/index-ChbLMdDF.css +0 -1
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,
|
|
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
|
-
|
|
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 (
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
|
696
|
-
var
|
|
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
|
|
727
|
-
for (const record of
|
|
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 =
|
|
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
|
-
|
|
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
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
826
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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 ??
|
|
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 =
|
|
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
|
|
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
|
-
|
|
926
|
-
|
|
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 -
|
|
934
|
-
|
|
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({
|
|
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
|
|
1214
|
-
|
|
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 ??
|
|
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
|
-
|
|
1366
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|