@singhak/nodeui-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1240 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ConfirmationStore: () => ConfirmationStore,
24
+ CpuProvider: () => CpuProvider,
25
+ DEFAULT_LOG_SIZE: () => DEFAULT_LOG_SIZE,
26
+ EnvProvider: () => EnvProvider,
27
+ EventLoopLagProvider: () => EventLoopLagProvider,
28
+ HealthProvider: () => HealthProvider,
29
+ HeapSnapshotProvider: () => HeapSnapshotProvider,
30
+ LogsProvider: () => LogsProvider,
31
+ MemoryProvider: () => MemoryProvider,
32
+ MetricsProvider: () => MetricsProvider,
33
+ ProviderRegistry: () => ProviderRegistry,
34
+ RequestsProvider: () => RequestsProvider,
35
+ RingBuffer: () => RingBuffer,
36
+ RoutesProvider: () => RoutesProvider,
37
+ SECRET_KEY_PATTERN: () => SECRET_KEY_PATTERN,
38
+ SECRET_MASKED: () => SECRET_MASKED,
39
+ Sampler: () => Sampler,
40
+ StartupTracker: () => StartupTracker,
41
+ createNodeUI: () => createNodeUI,
42
+ extractRoutes: () => extractRoutes,
43
+ interceptConsole: () => interceptConsole,
44
+ isLoopbackAddress: () => isLoopbackAddress,
45
+ maskSecrets: () => maskSecrets,
46
+ resolveActivation: () => resolveActivation,
47
+ resolveStaticAsset: () => resolveStaticAsset,
48
+ serializeEnvelope: () => serializeEnvelope,
49
+ startSse: () => startSse
50
+ });
51
+ module.exports = __toCommonJS(index_exports);
52
+
53
+ // src/constants.ts
54
+ var SECRET_MASKED = "[REDACTED]";
55
+ var DEFAULT_LOG_SIZE = 500;
56
+
57
+ // src/ring-buffer.ts
58
+ var RingBuffer = class {
59
+ buffer;
60
+ head = 0;
61
+ count = 0;
62
+ capacity;
63
+ constructor(size) {
64
+ if (!Number.isInteger(size) || size <= 0) {
65
+ throw new Error("RingBuffer size must be a positive integer");
66
+ }
67
+ this.capacity = size;
68
+ this.buffer = new Array(size);
69
+ }
70
+ push(item) {
71
+ this.buffer[(this.head + this.count) % this.capacity] = item;
72
+ if (this.count < this.capacity) {
73
+ this.count += 1;
74
+ } else {
75
+ this.head = (this.head + 1) % this.capacity;
76
+ }
77
+ }
78
+ get length() {
79
+ return this.count;
80
+ }
81
+ toArray() {
82
+ return this.slice(0, this.count);
83
+ }
84
+ /**
85
+ * Returns up to `limit` items starting at `offset`, oldest first.
86
+ */
87
+ slice(offset = 0, limit) {
88
+ const capped = limit === void 0 ? this.count : Math.max(0, limit);
89
+ const out = [];
90
+ for (let i = 0; i < this.count && out.length < capped; i += 1) {
91
+ if (i >= offset) out.push(this.buffer[(this.head + i) % this.capacity]);
92
+ }
93
+ return out;
94
+ }
95
+ };
96
+
97
+ // src/safety.ts
98
+ var SECRET_KEY_PATTERN = /token|key|secret|password|credential/i;
99
+ function resolveActivation(env) {
100
+ if (env.NODEUI_ENABLED === "true") {
101
+ return { active: true, reason: "activated by NODEUI_ENABLED=true" };
102
+ }
103
+ if (env.NODEUI_ENABLED === "false") {
104
+ return { active: false, reason: "inactive: explicitly disabled by NODEUI_ENABLED=false" };
105
+ }
106
+ if (env.NODE_ENV !== "production") {
107
+ return {
108
+ active: true,
109
+ reason: `activated by NODE_ENV="${env.NODE_ENV ?? "unset"}" (non-production)`
110
+ };
111
+ }
112
+ return {
113
+ active: false,
114
+ reason: "inactive: NODE_ENV=production and NODEUI_ENABLED is not 'true' (fail-closed)"
115
+ };
116
+ }
117
+ function isLoopbackAddress(address) {
118
+ if (!address) return false;
119
+ return address.startsWith("127.") || address === "::1" || address.startsWith("::ffff:127.") || address === "localhost";
120
+ }
121
+ function maskSecrets(value, pattern = SECRET_KEY_PATTERN) {
122
+ if (value !== null && typeof value === "object") {
123
+ if (Array.isArray(value)) {
124
+ return value.map((v) => maskSecrets(v, pattern));
125
+ }
126
+ const record = value;
127
+ const keys = Object.keys(record);
128
+ if (keys.length === 2 && keys.includes("key") && keys.includes("value") && typeof record.key === "string") {
129
+ const masked = pattern.test(record.key) ? SECRET_MASKED : record.value;
130
+ return { key: record.key, value: maskSecrets(masked, pattern) };
131
+ }
132
+ const out = {};
133
+ for (const [key, v] of Object.entries(value)) {
134
+ if (pattern.test(key)) {
135
+ out[key] = SECRET_MASKED;
136
+ } else {
137
+ out[key] = maskSecrets(v, pattern);
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+ return value;
143
+ }
144
+
145
+ // src/confirmations.ts
146
+ var import_node_crypto = require("crypto");
147
+ var ConfirmationStore = class {
148
+ constructor(ttlMs) {
149
+ this.ttlMs = ttlMs;
150
+ }
151
+ ttlMs;
152
+ nonces = /* @__PURE__ */ new Map();
153
+ issue() {
154
+ this.prune();
155
+ const nonce = (0, import_node_crypto.randomBytes)(16).toString("hex");
156
+ const expiresAtMs = Date.now() + this.ttlMs;
157
+ this.nonces.set(nonce, expiresAtMs);
158
+ return { nonce, expiresAtMs, ttlMs: this.ttlMs };
159
+ }
160
+ /** Consumes `nonce` and returns true only if it was valid and unexpired. */
161
+ consume(nonce) {
162
+ this.prune();
163
+ const expiresAt = this.nonces.get(nonce);
164
+ if (expiresAt === void 0) return false;
165
+ this.nonces.delete(nonce);
166
+ return Date.now() <= expiresAt;
167
+ }
168
+ /** Number of currently valid nonces (after pruning). */
169
+ size() {
170
+ this.prune();
171
+ return this.nonces.size;
172
+ }
173
+ /** Removes expired nonces. */
174
+ prune() {
175
+ const now = Date.now();
176
+ for (const [nonce, expiresAt] of this.nonces) {
177
+ if (expiresAt <= now) this.nonces.delete(nonce);
178
+ }
179
+ }
180
+ };
181
+
182
+ // src/registry.ts
183
+ var ProviderRegistry = class {
184
+ providers = /* @__PURE__ */ new Map();
185
+ register(provider) {
186
+ this.providers.set(provider.id, provider);
187
+ }
188
+ get(id) {
189
+ return this.providers.get(id);
190
+ }
191
+ ids() {
192
+ return [...this.providers.keys()];
193
+ }
194
+ all() {
195
+ return [...this.providers.values()];
196
+ }
197
+ };
198
+
199
+ // src/static.ts
200
+ var import_node_fs = require("fs");
201
+ var import_node_path = require("path");
202
+ var MIME_TYPES = {
203
+ ".html": "text/html; charset=utf-8",
204
+ ".js": "text/javascript; charset=utf-8",
205
+ ".mjs": "text/javascript; charset=utf-8",
206
+ ".css": "text/css; charset=utf-8",
207
+ ".svg": "image/svg+xml",
208
+ ".json": "application/json; charset=utf-8",
209
+ ".png": "image/png",
210
+ ".ico": "image/x-icon",
211
+ ".map": "application/json; charset=utf-8",
212
+ ".woff": "font/woff",
213
+ ".woff2": "font/woff2",
214
+ ".webmanifest": "application/manifest+json"
215
+ };
216
+ function resolveStaticAsset(rootDir, urlPath) {
217
+ const relative = urlPath === "" ? "index.html" : urlPath.replace(/^\/+/, "");
218
+ const candidate = (0, import_node_path.resolve)(rootDir, relative);
219
+ const rootWithSep = rootDir.endsWith(import_node_path.sep) ? rootDir : rootDir + import_node_path.sep;
220
+ if (candidate !== rootDir && !candidate.startsWith(rootWithSep)) {
221
+ return null;
222
+ }
223
+ let file = candidate;
224
+ if ((0, import_node_fs.existsSync)(file) && (0, import_node_fs.statSync)(file).isDirectory()) {
225
+ file = (0, import_node_path.join)(file, "index.html");
226
+ }
227
+ if (!(0, import_node_fs.existsSync)(file) || !(0, import_node_fs.statSync)(file).isFile()) {
228
+ file = (0, import_node_path.join)(rootDir, "index.html");
229
+ }
230
+ if (!(0, import_node_fs.existsSync)(file)) {
231
+ return null;
232
+ }
233
+ const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
234
+ const stat2 = (0, import_node_fs.statSync)(file);
235
+ return {
236
+ content: (0, import_node_fs.createReadStream)(file),
237
+ contentType: MIME_TYPES[ext] ?? "application/octet-stream",
238
+ length: stat2.size
239
+ };
240
+ }
241
+
242
+ // src/providers/sampler.ts
243
+ var Sampler = class {
244
+ constructor(opts) {
245
+ this.opts = opts;
246
+ }
247
+ opts;
248
+ timer = null;
249
+ start() {
250
+ if (this.timer) return;
251
+ const run = () => {
252
+ try {
253
+ this.opts.onSample(this.opts.collect());
254
+ } catch {
255
+ }
256
+ };
257
+ this.timer = setInterval(run, this.opts.intervalMs);
258
+ run();
259
+ }
260
+ stop() {
261
+ if (this.timer) {
262
+ clearInterval(this.timer);
263
+ this.timer = null;
264
+ }
265
+ }
266
+ get isRunning() {
267
+ return this.timer !== null;
268
+ }
269
+ };
270
+
271
+ // src/providers/startup-tracker.ts
272
+ var StartupTracker = class {
273
+ startedAtMs = Date.now();
274
+ marks = [];
275
+ mark(name) {
276
+ const atMs = Date.now();
277
+ this.marks.push({ name, atMs, sinceFirstMs: atMs - this.startedAtMs });
278
+ }
279
+ getData() {
280
+ return { startedAtMs: this.startedAtMs, marks: [...this.marks] };
281
+ }
282
+ reset() {
283
+ this.startedAtMs = Date.now();
284
+ this.marks = [];
285
+ }
286
+ };
287
+
288
+ // src/providers/memory.ts
289
+ var import_node_os = require("os");
290
+ var ZEROED = {
291
+ heapUsed: 0,
292
+ heapTotal: 0,
293
+ rss: 0,
294
+ external: 0,
295
+ totalMem: 0,
296
+ freeMem: 0,
297
+ sampleAtMs: 0
298
+ };
299
+ var MemoryProvider = class {
300
+ id = "memory";
301
+ sampler = null;
302
+ latest = ZEROED;
303
+ collect() {
304
+ const usage = process.memoryUsage();
305
+ return {
306
+ heapUsed: usage.heapUsed,
307
+ heapTotal: usage.heapTotal,
308
+ rss: usage.rss,
309
+ external: usage.external,
310
+ totalMem: (0, import_node_os.totalmem)(),
311
+ freeMem: (0, import_node_os.freemem)(),
312
+ sampleAtMs: Date.now()
313
+ };
314
+ }
315
+ start(ctx) {
316
+ if (this.sampler) return;
317
+ this.sampler = new Sampler({
318
+ intervalMs: ctx.config.pollIntervalMs,
319
+ collect: () => this.collect(),
320
+ onSample: (sample) => {
321
+ this.latest = sample;
322
+ ctx.store[this.id] = sample;
323
+ }
324
+ });
325
+ this.sampler.start();
326
+ }
327
+ stop() {
328
+ this.sampler?.stop();
329
+ this.sampler = null;
330
+ }
331
+ get() {
332
+ return { ok: true, data: this.latest };
333
+ }
334
+ };
335
+
336
+ // src/providers/cpu.ts
337
+ var defaultClock = {
338
+ nowUs: () => process.hrtime.bigint() / 1000n,
339
+ cpuUsage: () => process.cpuUsage()
340
+ };
341
+ var ZEROED2 = { userPercent: 0, systemPercent: 0, totalPercent: 0, sampleAtMs: 0 };
342
+ function clampPercent(v) {
343
+ return Math.min(100, Math.max(0, v));
344
+ }
345
+ var CpuProvider = class {
346
+ constructor(clock = defaultClock) {
347
+ this.clock = clock;
348
+ }
349
+ clock;
350
+ id = "cpu";
351
+ sampler = null;
352
+ latest = ZEROED2;
353
+ last = null;
354
+ /** Runs one sampling step immediately; called by the sampler on each tick. */
355
+ collect() {
356
+ const cpu = this.clock.cpuUsage();
357
+ const atUs = this.clock.nowUs();
358
+ let sample = { ...ZEROED2, sampleAtMs: Date.now() };
359
+ if (this.last) {
360
+ const userUs = cpu.user - this.last.cpu.user;
361
+ const systemUs = cpu.system - this.last.cpu.system;
362
+ const elapsedUs = Number(atUs - this.last.atUs);
363
+ if (elapsedUs > 0) {
364
+ sample = {
365
+ userPercent: clampPercent(userUs / elapsedUs * 100),
366
+ systemPercent: clampPercent(systemUs / elapsedUs * 100),
367
+ totalPercent: clampPercent((userUs + systemUs) / elapsedUs * 100),
368
+ sampleAtMs: Date.now()
369
+ };
370
+ }
371
+ }
372
+ this.last = { cpu, atUs };
373
+ this.latest = sample;
374
+ return sample;
375
+ }
376
+ start(ctx) {
377
+ if (this.sampler) return;
378
+ this.sampler = new Sampler({
379
+ intervalMs: ctx.config.pollIntervalMs,
380
+ collect: () => this.collect(),
381
+ onSample: (sample) => {
382
+ this.latest = sample;
383
+ ctx.store[this.id] = sample;
384
+ }
385
+ });
386
+ this.sampler.start();
387
+ }
388
+ stop() {
389
+ this.sampler?.stop();
390
+ this.sampler = null;
391
+ }
392
+ get() {
393
+ return { ok: true, data: this.latest };
394
+ }
395
+ };
396
+
397
+ // src/providers/event-loop.ts
398
+ var defaultClock2 = {
399
+ nowNs: () => process.hrtime.bigint()
400
+ };
401
+ var MAX_KEPT_SAMPLES = 600;
402
+ var ZEROED3 = {
403
+ currentMs: 0,
404
+ maxMs: 0,
405
+ avgMs: 0,
406
+ count: 0,
407
+ sampleAtMs: 0
408
+ };
409
+ var EventLoopLagProvider = class {
410
+ constructor(clock = defaultClock2) {
411
+ this.clock = clock;
412
+ }
413
+ clock;
414
+ id = "event-loop";
415
+ sampler = null;
416
+ samples = [];
417
+ latest = ZEROED3;
418
+ lastFireNs = null;
419
+ /** Runs one measurement tick; called by the sampler on each interval. */
420
+ collect(intervalMs) {
421
+ const nowNs = this.clock.nowNs();
422
+ let lagMs = 0;
423
+ if (this.lastFireNs !== null) {
424
+ const elapsedMs = Number(nowNs - this.lastFireNs) / 1e6;
425
+ lagMs = Math.max(0, elapsedMs - intervalMs);
426
+ }
427
+ this.lastFireNs = nowNs;
428
+ this.samples.push(lagMs);
429
+ if (this.samples.length > MAX_KEPT_SAMPLES) this.samples.shift();
430
+ const count = this.samples.length;
431
+ const total = this.samples.reduce((acc, v) => acc + v, 0);
432
+ const sample = {
433
+ currentMs: lagMs,
434
+ maxMs: Math.max(...this.samples),
435
+ avgMs: count > 0 ? total / count : 0,
436
+ count,
437
+ sampleAtMs: Date.now()
438
+ };
439
+ this.latest = sample;
440
+ return sample;
441
+ }
442
+ start(ctx) {
443
+ if (this.sampler) return;
444
+ this.lastFireNs = this.clock.nowNs();
445
+ this.sampler = new Sampler({
446
+ intervalMs: ctx.config.pollIntervalMs,
447
+ collect: () => this.collect(ctx.config.pollIntervalMs),
448
+ onSample: (sample) => {
449
+ this.latest = sample;
450
+ ctx.store[this.id] = sample;
451
+ }
452
+ });
453
+ this.sampler.start();
454
+ }
455
+ stop() {
456
+ this.sampler?.stop();
457
+ this.sampler = null;
458
+ }
459
+ get() {
460
+ return { ok: true, data: this.latest };
461
+ }
462
+ };
463
+
464
+ // src/providers/health.ts
465
+ var LAG_OK_MS = 50;
466
+ var LAG_DEGRADED_MS = 200;
467
+ var HealthProvider = class {
468
+ id = "health";
469
+ get(ctx) {
470
+ const loop = ctx.store["event-loop"];
471
+ const memory = ctx.store.memory;
472
+ const lagMs = typeof loop?.currentMs === "number" ? loop.currentMs : null;
473
+ const totalMem = typeof memory?.totalMem === "number" ? memory.totalMem : 0;
474
+ const rss = typeof memory?.rss === "number" ? memory.rss : 0;
475
+ const memoryUsedPercent = totalMem > 0 ? rss / totalMem * 100 : null;
476
+ let status;
477
+ let statusReason;
478
+ if (lagMs === null) {
479
+ status = "unknown";
480
+ statusReason = "no event-loop samples yet (open the Event-loop panel)";
481
+ } else if (lagMs < LAG_OK_MS) {
482
+ status = "ok";
483
+ statusReason = `event-loop lag ${lagMs.toFixed(1)}ms below ${LAG_OK_MS}ms`;
484
+ } else if (lagMs < LAG_DEGRADED_MS) {
485
+ status = "degraded";
486
+ statusReason = `event-loop lag ${lagMs.toFixed(1)}ms between ${LAG_OK_MS}ms and ${LAG_DEGRADED_MS}ms`;
487
+ } else {
488
+ status = "critical";
489
+ statusReason = `event-loop lag ${lagMs.toFixed(1)}ms above ${LAG_DEGRADED_MS}ms`;
490
+ }
491
+ return {
492
+ ok: true,
493
+ data: {
494
+ status,
495
+ statusReason,
496
+ uptimeSeconds: Math.round(process.uptime()),
497
+ pid: process.pid,
498
+ nodeVersion: process.version,
499
+ platform: process.platform,
500
+ eventLoopLagMs: lagMs,
501
+ memoryUsedPercent
502
+ }
503
+ };
504
+ }
505
+ };
506
+
507
+ // src/providers/heap-snapshot.ts
508
+ var import_promises = require("fs/promises");
509
+ var import_node_os2 = require("os");
510
+ var import_node_path2 = require("path");
511
+ var import_node_v8 = require("v8");
512
+ function defaultSnapshotDir() {
513
+ return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_os2.tmpdir)();
514
+ }
515
+ var HeapSnapshotProvider = class {
516
+ id = "heap-snapshot";
517
+ lastSnapshot = null;
518
+ get() {
519
+ return { ok: true, data: { supported: true, lastSnapshot: this.lastSnapshot } };
520
+ }
521
+ async takeSnapshot(ctx) {
522
+ const dir = ctx.config.heapSnapshotDir || defaultSnapshotDir();
523
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
524
+ const fileName = `nodeui-heap-${process.pid}-${stamp}.heapsnapshot`;
525
+ const filePath = (0, import_node_path2.join)(dir, fileName);
526
+ try {
527
+ await (0, import_promises.mkdir)(dir, { recursive: true });
528
+ (0, import_node_v8.writeHeapSnapshot)(filePath);
529
+ const fileStat = await (0, import_promises.stat)(filePath);
530
+ const data = {
531
+ fileName,
532
+ filePath,
533
+ sizeBytes: fileStat.size,
534
+ createdAtMs: Date.now()
535
+ };
536
+ this.lastSnapshot = data;
537
+ return { ok: true, data };
538
+ } catch (err) {
539
+ return {
540
+ ok: false,
541
+ error: {
542
+ code: "heap-snapshot-failed",
543
+ message: err instanceof Error ? err.message : "heap snapshot failed"
544
+ }
545
+ };
546
+ }
547
+ }
548
+ };
549
+
550
+ // src/providers/requests.ts
551
+ var RESPONSE_PAGE_SIZE = 100;
552
+ var RequestsProvider = class {
553
+ id = "requests";
554
+ buffer;
555
+ nextId = 1;
556
+ constructor(size) {
557
+ this.buffer = new RingBuffer(size);
558
+ }
559
+ record(entry) {
560
+ this.buffer.push({ id: this.nextId, ...entry });
561
+ this.nextId += 1;
562
+ }
563
+ get length() {
564
+ return this.buffer.length;
565
+ }
566
+ get() {
567
+ const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE));
568
+ return { ok: true, data: { total: this.buffer.length, entries } };
569
+ }
570
+ };
571
+
572
+ // src/providers/metrics.ts
573
+ var WINDOW_SECONDS = 60;
574
+ var MetricsProvider = class {
575
+ id = "metrics";
576
+ buckets = [];
577
+ nowMs;
578
+ constructor(nowMs = Date.now) {
579
+ this.nowMs = nowMs;
580
+ }
581
+ /** Ticks the current second bucket for one completed request. */
582
+ record(status) {
583
+ const ts = Math.floor(this.nowMs() / 1e3) * 1e3;
584
+ const last = this.buckets[this.buckets.length - 1];
585
+ if (last && last.ts === ts) {
586
+ last.requests += 1;
587
+ if (status >= 500) last.errors += 1;
588
+ return;
589
+ }
590
+ this.buckets.push({ ts, requests: 1, errors: status >= 500 ? 1 : 0 });
591
+ if (this.buckets.length > WINDOW_SECONDS + 1) {
592
+ this.buckets.splice(0, this.buckets.length - WINDOW_SECONDS);
593
+ }
594
+ }
595
+ get() {
596
+ const now = Math.floor(this.nowMs() / 1e3) * 1e3;
597
+ const start = now - (WINDOW_SECONDS - 1) * 1e3;
598
+ const byTs = new Map(this.buckets.map((b) => [b.ts, b]));
599
+ const buckets = [];
600
+ for (let ts = start; ts <= now; ts += 1e3) {
601
+ const existing = byTs.get(ts);
602
+ buckets.push(existing ? { ...existing } : { ts, requests: 0, errors: 0 });
603
+ }
604
+ return { ok: true, data: { buckets } };
605
+ }
606
+ };
607
+
608
+ // src/providers/env.ts
609
+ function stringify(value) {
610
+ if (value === void 0) return "";
611
+ if (typeof value === "string") return value;
612
+ return JSON.stringify(value);
613
+ }
614
+ function toEntries(source) {
615
+ return Object.entries(source).map(([key, value]) => ({ key, value: stringify(value) })).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
616
+ }
617
+ var EnvProvider = class {
618
+ id = "env";
619
+ get(ctx) {
620
+ const environment = toEntries(ctx.env);
621
+ const rawConfig = ctx.store["app-config"];
622
+ let config = null;
623
+ if (rawConfig !== void 0) {
624
+ const resolved = typeof rawConfig === "function" ? rawConfig() : rawConfig;
625
+ if (resolved !== void 0 && resolved !== null) {
626
+ config = toEntries(resolved);
627
+ }
628
+ }
629
+ return { ok: true, data: { environment, config } };
630
+ }
631
+ };
632
+
633
+ // src/providers/routes.ts
634
+ function joinPath(prefix, sub) {
635
+ if (!sub || sub === "/") return prefix || "/";
636
+ return `${prefix.replace(/\/+$/, "")}/${sub.replace(/^\/+/, "")}`;
637
+ }
638
+ function extractRoutes(router) {
639
+ const out = [];
640
+ const seen = /* @__PURE__ */ new Set();
641
+ let stack;
642
+ try {
643
+ stack = router?.stack;
644
+ } catch {
645
+ return out;
646
+ }
647
+ if (!Array.isArray(stack)) return out;
648
+ const walk = (layers, prefix) => {
649
+ for (const layer of layers) {
650
+ if (!layer || typeof layer !== "object") continue;
651
+ if (layer.route && typeof layer.route.path === "string") {
652
+ const path = joinPath(prefix, layer.route.path);
653
+ const methods = Object.entries(layer.route.methods ?? {}).filter(([, enabled]) => enabled).map(([method]) => method.toUpperCase());
654
+ const handler = layer.route.stack?.[0]?.handle?.name || "anonymous";
655
+ if (methods.length === 0) continue;
656
+ for (const method of methods) {
657
+ const key = `${method} ${path}`;
658
+ if (seen.has(key)) continue;
659
+ seen.add(key);
660
+ out.push({ method, path, handler });
661
+ }
662
+ continue;
663
+ }
664
+ const nested = layer.handle?.stack;
665
+ if (Array.isArray(nested)) {
666
+ walk(nested, joinPath(prefix, layer.path ?? ""));
667
+ }
668
+ }
669
+ };
670
+ try {
671
+ walk(stack, "");
672
+ } catch {
673
+ return [];
674
+ }
675
+ out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
676
+ return out;
677
+ }
678
+ var RoutesProvider = class {
679
+ id = "routes";
680
+ cache = /* @__PURE__ */ new WeakMap();
681
+ get(ctx) {
682
+ const router = ctx.store["express-router"];
683
+ if (!router) {
684
+ return {
685
+ ok: false,
686
+ error: {
687
+ code: "router-unavailable",
688
+ message: "No Express router captured yet. Send at least one request through the app."
689
+ }
690
+ };
691
+ }
692
+ if (typeof router !== "object" && typeof router !== "function") {
693
+ return {
694
+ ok: false,
695
+ error: {
696
+ code: "router-invalid",
697
+ message: "Captured router has an unexpected shape; route listing is unavailable."
698
+ }
699
+ };
700
+ }
701
+ let routes = this.cache.get(router);
702
+ if (!routes) {
703
+ routes = extractRoutes(router);
704
+ this.cache.set(router, routes);
705
+ }
706
+ return { ok: true, data: { routes } };
707
+ }
708
+ };
709
+
710
+ // src/providers/logs.ts
711
+ var CONSOLE_METHODS = [
712
+ ["debug", "debug"],
713
+ ["info", "info"],
714
+ ["log", "info"],
715
+ ["warn", "warn"],
716
+ ["error", "error"]
717
+ ];
718
+ function formatArg(arg) {
719
+ if (typeof arg === "string") return arg;
720
+ try {
721
+ const json = JSON.stringify(arg);
722
+ return json === void 0 ? String(arg) : json;
723
+ } catch {
724
+ return String(arg);
725
+ }
726
+ }
727
+ var refs = 0;
728
+ var pushes = /* @__PURE__ */ new Set();
729
+ var wrappers = /* @__PURE__ */ new Map();
730
+ var originals = {};
731
+ function consoleRef() {
732
+ return console;
733
+ }
734
+ function installWrappers() {
735
+ for (const [method, level] of CONSOLE_METHODS) {
736
+ const original = consoleRef()[method];
737
+ originals[method] = original;
738
+ const wrapper = (...args) => {
739
+ for (const push of pushes) {
740
+ try {
741
+ push({ level, message: args.map(formatArg).join(" ") });
742
+ } catch {
743
+ }
744
+ }
745
+ if (original) original(...args);
746
+ };
747
+ wrappers.set(method, wrapper);
748
+ consoleRef()[method] = wrapper;
749
+ }
750
+ }
751
+ function uninstallWrappers() {
752
+ for (const [method] of CONSOLE_METHODS) {
753
+ const current = consoleRef()[method];
754
+ if (current === wrappers.get(method)) {
755
+ const original = originals[method];
756
+ if (original) {
757
+ consoleRef()[method] = original;
758
+ } else {
759
+ delete consoleRef()[method];
760
+ }
761
+ }
762
+ }
763
+ wrappers.clear();
764
+ originals = {};
765
+ }
766
+ function interceptConsole(push) {
767
+ refs += 1;
768
+ pushes.add(push);
769
+ if (refs === 1) installWrappers();
770
+ return () => {
771
+ pushes.delete(push);
772
+ refs = Math.max(0, refs - 1);
773
+ if (refs === 0) uninstallWrappers();
774
+ };
775
+ }
776
+ var LogsProvider = class {
777
+ id = "logs";
778
+ buffer;
779
+ release = null;
780
+ constructor(size = DEFAULT_LOG_SIZE) {
781
+ this.buffer = new RingBuffer(size);
782
+ }
783
+ start() {
784
+ if (this.release) return;
785
+ this.release = interceptConsole(({ level, message }) => {
786
+ this.buffer.push({ level, message, timestamp: Date.now() });
787
+ });
788
+ }
789
+ stop() {
790
+ this.release?.();
791
+ this.release = null;
792
+ }
793
+ /** Pushes an external entry (logger adapter). */
794
+ addSource(entry) {
795
+ this.buffer.push({ ...entry, timestamp: Date.now() });
796
+ }
797
+ get(ctx) {
798
+ let entries = this.buffer.toArray();
799
+ const level = ctx.query?.level;
800
+ if (level) entries = entries.filter((e) => e.level === level);
801
+ const query = ctx.query?.query;
802
+ if (query) {
803
+ const needle = query.toLowerCase();
804
+ entries = entries.filter((e) => e.message.toLowerCase().includes(needle));
805
+ }
806
+ return { ok: true, data: { entries } };
807
+ }
808
+ };
809
+
810
+ // src/sse.ts
811
+ function startSse(res) {
812
+ let closed = false;
813
+ res.writeHead(200, {
814
+ "Content-Type": "text/event-stream",
815
+ "Cache-Control": "no-cache",
816
+ Connection: "keep-alive",
817
+ "X-Accel-Buffering": "no"
818
+ });
819
+ res.flushHeaders?.();
820
+ return {
821
+ send(payload) {
822
+ if (closed || res.destroyed) return;
823
+ res.write(`data: ${JSON.stringify(payload)}
824
+
825
+ `);
826
+ },
827
+ heartbeat() {
828
+ if (closed || res.destroyed) return;
829
+ res.write(":ping\n\n");
830
+ },
831
+ close() {
832
+ if (closed || res.destroyed) return;
833
+ closed = true;
834
+ res.end();
835
+ }
836
+ };
837
+ }
838
+
839
+ // src/server.ts
840
+ var import_node_os3 = require("os");
841
+ var import_node_path3 = require("path");
842
+ var STATIC_ROOT = (0, import_node_path3.resolve)(__dirname, "..", "static");
843
+ var SSE_HEARTBEAT_MS = 15e3;
844
+ function serializeEnvelope(envelope) {
845
+ return JSON.stringify(maskSecrets(envelope));
846
+ }
847
+ function pathnameOf(req) {
848
+ const raw = req.originalUrl ?? req.url ?? "/";
849
+ try {
850
+ return new URL(raw, "http://localhost").pathname;
851
+ } catch {
852
+ return raw;
853
+ }
854
+ }
855
+ function normalizePath(value) {
856
+ const trimmed = value.trim();
857
+ if (!trimmed.startsWith("/")) throw new Error(`NODEUI_PATH must start with "/", got "${value}"`);
858
+ return trimmed.replace(/\/+$/, "") || "/";
859
+ }
860
+ function positiveInt(value, fallback, name) {
861
+ const n = value === void 0 ? fallback : value;
862
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} must be a positive integer`);
863
+ return n;
864
+ }
865
+ function createNodeUI(options = {}) {
866
+ const env = options.env ?? process.env;
867
+ const activation = options.enabled !== void 0 ? {
868
+ active: options.enabled,
869
+ reason: options.enabled ? "explicitly enabled via options" : "explicitly disabled via options"
870
+ } : resolveActivation(env);
871
+ const path = normalizePath(options.path ?? env.NODEUI_PATH ?? "/nodeui");
872
+ const host = options.host ?? env.NODEUI_HOST ?? "127.0.0.1";
873
+ const config = {
874
+ path,
875
+ host,
876
+ port: options.port ?? (env.NODEUI_PORT ? Number(env.NODEUI_PORT) : 0),
877
+ requestLogSize: positiveInt(
878
+ options.requestLogSize,
879
+ env.NODEUI_REQUEST_LOG_SIZE ? Number(env.NODEUI_REQUEST_LOG_SIZE) : 500,
880
+ "NODEUI_REQUEST_LOG_SIZE"
881
+ ),
882
+ logSize: positiveInt(
883
+ options.logSize,
884
+ env.NODEUI_LOG_SIZE ? Number(env.NODEUI_LOG_SIZE) : DEFAULT_LOG_SIZE,
885
+ "NODEUI_LOG_SIZE"
886
+ ),
887
+ pollIntervalMs: positiveInt(
888
+ options.pollIntervalMs,
889
+ env.NODEUI_POLL_INTERVAL_MS ? Number(env.NODEUI_POLL_INTERVAL_MS) : 2e3,
890
+ "NODEUI_POLL_INTERVAL_MS"
891
+ ),
892
+ enabled: activation.active,
893
+ activationReason: activation.reason,
894
+ maskSecrets: options.maskSecrets ?? true,
895
+ inactivityTimeoutMs: positiveInt(
896
+ options.inactivityTimeoutMs,
897
+ env.NODEUI_INACTIVITY_TIMEOUT_MS ? Number(env.NODEUI_INACTIVITY_TIMEOUT_MS) : 6e4,
898
+ "NODEUI_INACTIVITY_TIMEOUT_MS"
899
+ ),
900
+ confirmTtlMs: positiveInt(
901
+ options.confirmTtlMs,
902
+ env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
903
+ "NODEUI_CONFIRM_TTL_MS"
904
+ ),
905
+ heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_os3.tmpdir)()
906
+ };
907
+ if (!isLoopbackAddress(config.host)) {
908
+ console.warn(
909
+ `[nodeui] NODEUI_HOST is set to non-loopback "${config.host}". The developer console will be reachable from other hosts; only do this deliberately.`
910
+ );
911
+ }
912
+ const ctx = { config, env, store: {} };
913
+ if (options.config !== void 0) {
914
+ ctx.store["app-config"] = options.config;
915
+ }
916
+ const registry = new ProviderRegistry();
917
+ registry.register(new HealthProvider());
918
+ registry.register(new MemoryProvider());
919
+ registry.register(new CpuProvider());
920
+ registry.register(new EventLoopLagProvider());
921
+ registry.register(new HeapSnapshotProvider());
922
+ const startupTracker = new StartupTracker();
923
+ startupTracker.mark("nodeui.init");
924
+ const startupProvider = {
925
+ id: "startup",
926
+ get: () => ({ ok: true, data: startupTracker.getData() })
927
+ };
928
+ registry.register(startupProvider);
929
+ const requestsProvider = new RequestsProvider(config.requestLogSize);
930
+ registry.register(requestsProvider);
931
+ const metricsProvider = new MetricsProvider();
932
+ registry.register(metricsProvider);
933
+ const logsProvider = new LogsProvider(config.logSize);
934
+ registry.register(logsProvider);
935
+ const envProvider = new EnvProvider();
936
+ registry.register(envProvider);
937
+ const routesProvider = new RoutesProvider();
938
+ registry.register(routesProvider);
939
+ const confirmations = new ConfirmationStore(config.confirmTtlMs);
940
+ const activeProviders = /* @__PURE__ */ new Set();
941
+ let inactivityTimer = null;
942
+ function stopAll() {
943
+ for (const id of activeProviders) {
944
+ registry.get(id)?.stop?.(ctx);
945
+ }
946
+ activeProviders.clear();
947
+ if (inactivityTimer) {
948
+ clearTimeout(inactivityTimer);
949
+ inactivityTimer = null;
950
+ }
951
+ }
952
+ function resetInactivityTimer() {
953
+ if (inactivityTimer) clearTimeout(inactivityTimer);
954
+ inactivityTimer = setTimeout(stopAll, config.inactivityTimeoutMs);
955
+ if (typeof inactivityTimer.unref === "function") inactivityTimer.unref();
956
+ }
957
+ function ensureActive(id) {
958
+ if (!activeProviders.has(id)) {
959
+ registry.get(id)?.start?.(ctx);
960
+ activeProviders.add(id);
961
+ }
962
+ resetInactivityTimer();
963
+ }
964
+ function sendJson(res, status, envelope) {
965
+ const body = serializeEnvelope(envelope);
966
+ res.writeHead(status, {
967
+ "Content-Type": "application/json",
968
+ "Content-Length": Buffer.byteLength(body),
969
+ "Cache-Control": "no-store"
970
+ });
971
+ res.end(body);
972
+ }
973
+ function notFound(res) {
974
+ sendJson(res, 404, { ok: false, error: { code: "not-found", message: "Not found" } });
975
+ }
976
+ function isUnderPath(urlPath) {
977
+ return urlPath === path || urlPath.startsWith(`${path}/`);
978
+ }
979
+ async function servePanel(res, id, query) {
980
+ ensureActive(id);
981
+ const provider = registry.get(id);
982
+ if (!provider) return notFound(res);
983
+ const requestCtx = query ? { ...ctx, query } : ctx;
984
+ const result = await provider.get(requestCtx);
985
+ sendJson(res, result.ok ? 200 : 500, result);
986
+ }
987
+ function configData() {
988
+ return {
989
+ enabled: config.enabled,
990
+ activationReason: config.activationReason,
991
+ path: config.path,
992
+ host: config.host,
993
+ port: config.port,
994
+ requestLogSize: config.requestLogSize,
995
+ logSize: config.logSize,
996
+ pollIntervalMs: config.pollIntervalMs,
997
+ panels: registry.ids(),
998
+ masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source }
999
+ };
1000
+ }
1001
+ function issueConfirmation(res) {
1002
+ const issued = confirmations.issue();
1003
+ sendJson(res, 200, { ok: true, data: issued });
1004
+ }
1005
+ async function takeHeapSnapshot(req, res) {
1006
+ const header = req.headers["x-nodeui-confirm"];
1007
+ const nonce = Array.isArray(header) ? header[0] : header;
1008
+ if (typeof nonce !== "string" || !confirmations.consume(nonce)) {
1009
+ sendJson(res, 409, {
1010
+ ok: false,
1011
+ error: {
1012
+ code: "confirmation-required",
1013
+ message: `This mutating action needs a fresh confirmation nonce. Issue one via POST ${path}/api/confirmations and repeat the request with an x-nodeui-confirm header.`
1014
+ }
1015
+ });
1016
+ return;
1017
+ }
1018
+ const heapProvider = registry.get("heap-snapshot");
1019
+ if (!heapProvider || !("takeSnapshot" in heapProvider)) return notFound(res);
1020
+ const result = await heapProvider.takeSnapshot(
1021
+ ctx
1022
+ );
1023
+ sendJson(res, result.ok ? 200 : 500, result);
1024
+ }
1025
+ async function handleLive(req, res) {
1026
+ const search = new URL(req.url ?? "/", "http://localhost").searchParams;
1027
+ const requested = (search.get("panels") ?? "").split(",").filter(Boolean);
1028
+ const panels = (requested.length > 0 ? requested.filter((id) => registry.get(id) !== void 0) : registry.ids()).filter((value, index, self) => self.indexOf(value) === index);
1029
+ if (panels.length === 0) return notFound(res);
1030
+ const stream = startSse(res);
1031
+ const push = async () => {
1032
+ for (const id of panels) {
1033
+ ensureActive(id);
1034
+ const provider = registry.get(id);
1035
+ if (!provider) continue;
1036
+ const result = await provider.get(ctx);
1037
+ stream.send({ panel: id, envelope: result });
1038
+ }
1039
+ };
1040
+ void push();
1041
+ const pushTimer = setInterval(() => void push(), config.pollIntervalMs);
1042
+ const heartbeatTimer = setInterval(() => stream.heartbeat(), SSE_HEARTBEAT_MS);
1043
+ if (typeof pushTimer.unref === "function") pushTimer.unref();
1044
+ if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
1045
+ const cleanup = () => {
1046
+ clearInterval(pushTimer);
1047
+ clearInterval(heartbeatTimer);
1048
+ stream.close();
1049
+ };
1050
+ req.on("close", cleanup);
1051
+ res.on("close", cleanup);
1052
+ }
1053
+ async function handleApi(req, res, apiPath) {
1054
+ const method = (req.method ?? "GET").toUpperCase();
1055
+ if (method === "GET" && apiPath === "/config") {
1056
+ sendJson(res, 200, { ok: true, data: configData() });
1057
+ return;
1058
+ }
1059
+ if (method === "GET" && apiPath === "/live") {
1060
+ return handleLive(req, res);
1061
+ }
1062
+ if (method === "GET") {
1063
+ const panel = registry.get(apiPath.slice(1));
1064
+ if (panel) {
1065
+ const query = Object.fromEntries(new URL(req.url ?? "/", "http://localhost").searchParams);
1066
+ return servePanel(res, panel.id, query);
1067
+ }
1068
+ }
1069
+ if (method === "POST") {
1070
+ if (apiPath === "/confirmations") {
1071
+ issueConfirmation(res);
1072
+ return;
1073
+ }
1074
+ if (apiPath === "/heap-snapshot") {
1075
+ return takeHeapSnapshot(req, res);
1076
+ }
1077
+ }
1078
+ notFound(res);
1079
+ }
1080
+ function serveStatic(req, res, urlPath) {
1081
+ const relative = urlPath.slice(path.length);
1082
+ const asset = resolveStaticAsset(STATIC_ROOT, relative);
1083
+ if (!asset) {
1084
+ sendJson(res, 404, { ok: false, error: { code: "not-found", message: "Not found" } });
1085
+ return;
1086
+ }
1087
+ res.writeHead(200, {
1088
+ "Content-Type": asset.contentType,
1089
+ "Content-Length": asset.length,
1090
+ "Cache-Control": "no-cache"
1091
+ });
1092
+ asset.content.pipe(res);
1093
+ }
1094
+ function guardLoopback(req, res) {
1095
+ if (isLoopbackAddress(config.host)) {
1096
+ const remote = req.socket.remoteAddress;
1097
+ if (!isLoopbackAddress(remote)) {
1098
+ sendJson(res, 403, {
1099
+ ok: false,
1100
+ error: {
1101
+ code: "forbidden",
1102
+ message: "nodeui is loopback-only; requests from other hosts are rejected"
1103
+ }
1104
+ });
1105
+ return false;
1106
+ }
1107
+ }
1108
+ return true;
1109
+ }
1110
+ async function handleNodeUIPath(req, res, urlPath) {
1111
+ if (!guardLoopback(req, res)) return;
1112
+ const apiBase = `${path}/api`;
1113
+ if (urlPath === apiBase || urlPath.startsWith(`${apiBase}/`)) {
1114
+ await handleApi(req, res, urlPath.slice(apiBase.length));
1115
+ return;
1116
+ }
1117
+ serveStatic(req, res, urlPath);
1118
+ }
1119
+ function captureRouter(req) {
1120
+ if (ctx.store["express-router"] !== void 0) return;
1121
+ const app = req.app;
1122
+ if (!app) return;
1123
+ let router;
1124
+ try {
1125
+ router = app._router;
1126
+ } catch {
1127
+ router = void 0;
1128
+ }
1129
+ if (router === void 0) {
1130
+ try {
1131
+ router = app.router;
1132
+ } catch {
1133
+ router = void 0;
1134
+ }
1135
+ }
1136
+ if (router) {
1137
+ ctx.store["express-router"] = router;
1138
+ }
1139
+ }
1140
+ function recordAppRequest(req, res) {
1141
+ const started = process.hrtime.bigint();
1142
+ const timestampMs = Date.now();
1143
+ res.on("finish", () => {
1144
+ const durationMs = Number(process.hrtime.bigint() - started) / 1e6;
1145
+ requestsProvider.record({
1146
+ method: req.method ?? "?",
1147
+ path: pathnameOf(req),
1148
+ status: res.statusCode,
1149
+ durationMs,
1150
+ timestampMs,
1151
+ ip: req.socket.remoteAddress ?? "unknown"
1152
+ });
1153
+ metricsProvider.record(res.statusCode);
1154
+ });
1155
+ }
1156
+ const server = {
1157
+ get config() {
1158
+ return config;
1159
+ },
1160
+ get active() {
1161
+ return config.enabled;
1162
+ },
1163
+ get activationReason() {
1164
+ return config.activationReason;
1165
+ },
1166
+ middleware() {
1167
+ return (req, res, next) => {
1168
+ captureRouter(req);
1169
+ if (!config.enabled) {
1170
+ next();
1171
+ return;
1172
+ }
1173
+ const urlPath = pathnameOf(req);
1174
+ if (isUnderPath(urlPath)) {
1175
+ void handleNodeUIPath(req, res, urlPath);
1176
+ return;
1177
+ }
1178
+ recordAppRequest(req, res);
1179
+ next();
1180
+ };
1181
+ },
1182
+ async handle(req, res) {
1183
+ captureRouter(req);
1184
+ if (!config.enabled) {
1185
+ notFound(res);
1186
+ return;
1187
+ }
1188
+ const urlPath = pathnameOf(req);
1189
+ if (isUnderPath(urlPath)) {
1190
+ await handleNodeUIPath(req, res, urlPath);
1191
+ return;
1192
+ }
1193
+ notFound(res);
1194
+ },
1195
+ mark(name) {
1196
+ startupTracker.mark(name);
1197
+ },
1198
+ isProviderActive(id) {
1199
+ return activeProviders.has(id);
1200
+ },
1201
+ addLogSource(entry) {
1202
+ logsProvider.addSource(entry);
1203
+ },
1204
+ shutdown() {
1205
+ stopAll();
1206
+ }
1207
+ };
1208
+ return server;
1209
+ }
1210
+ // Annotate the CommonJS export names for ESM import in node:
1211
+ 0 && (module.exports = {
1212
+ ConfirmationStore,
1213
+ CpuProvider,
1214
+ DEFAULT_LOG_SIZE,
1215
+ EnvProvider,
1216
+ EventLoopLagProvider,
1217
+ HealthProvider,
1218
+ HeapSnapshotProvider,
1219
+ LogsProvider,
1220
+ MemoryProvider,
1221
+ MetricsProvider,
1222
+ ProviderRegistry,
1223
+ RequestsProvider,
1224
+ RingBuffer,
1225
+ RoutesProvider,
1226
+ SECRET_KEY_PATTERN,
1227
+ SECRET_MASKED,
1228
+ Sampler,
1229
+ StartupTracker,
1230
+ createNodeUI,
1231
+ extractRoutes,
1232
+ interceptConsole,
1233
+ isLoopbackAddress,
1234
+ maskSecrets,
1235
+ resolveActivation,
1236
+ resolveStaticAsset,
1237
+ serializeEnvelope,
1238
+ startSse
1239
+ });
1240
+ //# sourceMappingURL=index.cjs.map