@mandujs/core 0.41.2 → 0.43.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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,334 +1,334 @@
1
- /**
2
- * Phase 17 — lightweight in-process metrics + heap snapshot.
3
- *
4
- * Two outputs are derived from the same underlying state:
5
- *
6
- * 1. JSON snapshot (`/_mandu/heap`) — human-oriented debug dump.
7
- * 2. Prometheus text exposition (`/_mandu/metrics`) — scraper-friendly.
8
- *
9
- * Design rules:
10
- * - Hand-rolled. No new runtime deps.
11
- * - Zero allocation in the hot path — counters are plain numbers.
12
- * - Cache sizes are provided through a registry so each cache lives
13
- * in its own module (no circular imports). Call
14
- * `registerCacheSize("patternCache", () => cache.size)` at module
15
- * init; the metrics collector calls each reporter lazily on scrape.
16
- * - Label cardinality is strictly bounded — HTTP request counts are
17
- * keyed by `{method, statusClass}` where statusClass is `2xx`/`3xx`/
18
- * `4xx`/`5xx`/`other`, not the raw status. Prevents runaway series.
19
- */
20
-
21
- export type CacheName = "patternCache" | "fetchCache" | "perFileTimers" | string;
22
-
23
- /**
24
- * Registry of cache-size reporters. Each reporter is called at scrape
25
- * time and must return the current cache entry count. Reporters that
26
- * throw or return a non-finite number are treated as `0` (defence
27
- * against a half-torn-down subsystem).
28
- */
29
- const cacheSizeReporters = new Map<CacheName, () => number>();
30
-
31
- /**
32
- * Register (or replace) a cache-size reporter. Callers typically wire
33
- * this at module init:
34
- *
35
- * const cache = new LRUCache<string, Compiled>({ maxSize: 200 });
36
- * registerCacheSize("patternCache", () => cache.size);
37
- */
38
- export function registerCacheSize(name: CacheName, reporter: () => number): void {
39
- cacheSizeReporters.set(name, reporter);
40
- }
41
-
42
- /**
43
- * Unregister a reporter. Used by hot-reload paths that rebuild their
44
- * cache under a fresh reference.
45
- */
46
- export function unregisterCacheSize(name: CacheName): boolean {
47
- return cacheSizeReporters.delete(name);
48
- }
49
-
50
- /**
51
- * For tests — drop every reporter. Production code should never need this.
52
- */
53
- export function clearCacheSizeReporters(): void {
54
- cacheSizeReporters.clear();
55
- }
56
-
57
- /**
58
- * Collect current sizes. Missing reporters simply don't appear. A
59
- * thrown / non-finite reporter contributes `0` and is silently logged
60
- * on the event bus (best-effort — we never propagate).
61
- */
62
- export function collectCacheSizes(): Record<string, number> {
63
- const out: Record<string, number> = {};
64
- for (const [name, reporter] of cacheSizeReporters) {
65
- let size = 0;
66
- try {
67
- const v = reporter();
68
- size = typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0;
69
- } catch {
70
- size = 0;
71
- }
72
- out[name] = size;
73
- }
74
- return out;
75
- }
76
-
77
- // --------------------------------------------------------------------
78
- // HTTP request counter
79
- // --------------------------------------------------------------------
80
-
81
- /**
82
- * `Map` keyed by `"METHOD statusClass"` for bounded cardinality.
83
- * E.g. `"GET 2xx"` → 41.
84
- */
85
- const httpRequestCounter = new Map<string, number>();
86
-
87
- const STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx", "other"] as const;
88
- type StatusClass = (typeof STATUS_CLASSES)[number];
89
-
90
- function classifyStatus(status: number): StatusClass {
91
- if (status >= 200 && status < 300) return "2xx";
92
- if (status >= 300 && status < 400) return "3xx";
93
- if (status >= 400 && status < 500) return "4xx";
94
- if (status >= 500 && status < 600) return "5xx";
95
- return "other";
96
- }
97
-
98
- function normalizeMethod(method: string | undefined): string {
99
- if (!method) return "UNKNOWN";
100
- const upper = method.toUpperCase();
101
- // Whitelist standard methods so a rogue "evil\n" header can't break
102
- // the Prometheus line format. Anything unknown bucket under OTHER.
103
- const allowed = new Set([
104
- "GET",
105
- "HEAD",
106
- "POST",
107
- "PUT",
108
- "DELETE",
109
- "PATCH",
110
- "OPTIONS",
111
- "TRACE",
112
- "CONNECT",
113
- ]);
114
- return allowed.has(upper) ? upper : "OTHER";
115
- }
116
-
117
- /**
118
- * Bump the request counter. Safe to call on every request path — O(1).
119
- * The method/status pair is normalised to a bounded cardinality set.
120
- */
121
- export function recordHttpRequest(method: string | undefined, status: number): void {
122
- const m = normalizeMethod(method);
123
- const cls = classifyStatus(status);
124
- const key = `${m} ${cls}`;
125
- httpRequestCounter.set(key, (httpRequestCounter.get(key) ?? 0) + 1);
126
- }
127
-
128
- /** Reset counters. Used by tests; prod rarely needs this. */
129
- export function resetHttpRequestCounter(): void {
130
- httpRequestCounter.clear();
131
- }
132
-
133
- /** Snapshot current counts for programmatic inspection. */
134
- export function getHttpRequestCounts(): Array<{ method: string; statusClass: StatusClass; value: number }> {
135
- const out: Array<{ method: string; statusClass: StatusClass; value: number }> = [];
136
- for (const [key, value] of httpRequestCounter) {
137
- const [method, statusClass] = key.split(" ");
138
- out.push({ method: method!, statusClass: statusClass as StatusClass, value });
139
- }
140
- // Stable order → deterministic Prometheus output (aids scraping + tests).
141
- out.sort((a, b) => {
142
- if (a.method !== b.method) return a.method < b.method ? -1 : 1;
143
- return a.statusClass < b.statusClass ? -1 : 1;
144
- });
145
- return out;
146
- }
147
-
148
- // --------------------------------------------------------------------
149
- // Heap snapshot
150
- // --------------------------------------------------------------------
151
-
152
- export interface HeapSnapshot {
153
- /** Unix epoch millis when snapshot was captured. */
154
- timestamp: number;
155
- /** Process uptime in seconds. */
156
- uptime: number;
157
- /** `process.memoryUsage()` — always available. */
158
- process: {
159
- rss: number;
160
- heapTotal: number;
161
- heapUsed: number;
162
- external: number;
163
- arrayBuffers: number;
164
- };
165
- /** `Bun.memoryUsage()` if running on Bun and the API is available. */
166
- bun?: Record<string, number>;
167
- /** Current cache entry counts, keyed by reporter name. */
168
- caches: Record<string, number>;
169
- }
170
-
171
- /**
172
- * Assemble a heap snapshot. This is the single source of truth for
173
- * both the JSON endpoint and the Prometheus exporter.
174
- */
175
- export function collectHeapSnapshot(): HeapSnapshot {
176
- const mem = process.memoryUsage();
177
- const snapshot: HeapSnapshot = {
178
- timestamp: Date.now(),
179
- uptime: process.uptime(),
180
- process: {
181
- rss: mem.rss,
182
- heapTotal: mem.heapTotal,
183
- heapUsed: mem.heapUsed,
184
- external: mem.external,
185
- arrayBuffers: mem.arrayBuffers ?? 0,
186
- },
187
- caches: collectCacheSizes(),
188
- };
189
-
190
- // Bun.memoryUsage() is currently a non-standard helper; we feature-
191
- // detect to stay forward-compatible with other runtimes (Node tests,
192
- // edge workers) where the global is absent.
193
- const bunGlobal = (globalThis as { Bun?: { memoryUsage?: () => Record<string, number> } }).Bun;
194
- if (bunGlobal?.memoryUsage) {
195
- try {
196
- const bunMem = bunGlobal.memoryUsage();
197
- if (bunMem && typeof bunMem === "object") {
198
- snapshot.bun = bunMem;
199
- }
200
- } catch {
201
- // Swallow — not every Bun version ships this.
202
- }
203
- }
204
-
205
- return snapshot;
206
- }
207
-
208
- // --------------------------------------------------------------------
209
- // Prometheus text exposition
210
- // --------------------------------------------------------------------
211
-
212
- /**
213
- * Escape a label value per Prometheus text format §
214
- *
215
- * - backslash → `\\`
216
- * - newline → `\n`
217
- * - double-quote → `\"`
218
- *
219
- * We never inline tab/CR because `normalizeMethod` already clamps
220
- * methods to uppercase ASCII and statusClass is a fixed enum.
221
- */
222
- function escapeLabelValue(value: string): string {
223
- return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
224
- }
225
-
226
- /**
227
- * Render the current metric state in Prometheus text format. Each
228
- * metric starts with `# HELP` + `# TYPE` headers per the spec.
229
- *
230
- * The output is deterministic (sorted labels, stable order) so tests
231
- * can snapshot against it and scrapers get a consistent diff.
232
- */
233
- export function renderPrometheus(snapshot?: HeapSnapshot): string {
234
- const snap = snapshot ?? collectHeapSnapshot();
235
- const lines: string[] = [];
236
-
237
- // Node/Bun heap gauges.
238
- lines.push(
239
- "# HELP nodejs_heap_used_bytes Process heap used in bytes (process.memoryUsage().heapUsed).",
240
- "# TYPE nodejs_heap_used_bytes gauge",
241
- `nodejs_heap_used_bytes ${snap.process.heapUsed}`,
242
- "# HELP nodejs_heap_total_bytes Process heap total in bytes (process.memoryUsage().heapTotal).",
243
- "# TYPE nodejs_heap_total_bytes gauge",
244
- `nodejs_heap_total_bytes ${snap.process.heapTotal}`,
245
- "# HELP nodejs_external_bytes Process external memory in bytes (process.memoryUsage().external).",
246
- "# TYPE nodejs_external_bytes gauge",
247
- `nodejs_external_bytes ${snap.process.external}`,
248
- "# HELP nodejs_rss_bytes Process resident set size in bytes.",
249
- "# TYPE nodejs_rss_bytes gauge",
250
- `nodejs_rss_bytes ${snap.process.rss}`,
251
- "# HELP nodejs_uptime_seconds Process uptime in seconds.",
252
- "# TYPE nodejs_uptime_seconds gauge",
253
- `nodejs_uptime_seconds ${snap.uptime.toFixed(3)}`,
254
- );
255
-
256
- // Cache entry counts — one line per registered reporter. Keys are
257
- // emitted in sorted order so the output is stable across scrapes.
258
- lines.push(
259
- "# HELP mandu_cache_entries Current entry count for Mandu internal caches.",
260
- "# TYPE mandu_cache_entries gauge",
261
- );
262
- const cacheNames = Object.keys(snap.caches).sort();
263
- if (cacheNames.length === 0) {
264
- // Prometheus requires at least one sample for the series to be
265
- // useful; emit a zero-valued placeholder so scrapers don't drop
266
- // the metric entirely on an empty registry.
267
- lines.push(`mandu_cache_entries{cache="none"} 0`);
268
- } else {
269
- for (const name of cacheNames) {
270
- lines.push(`mandu_cache_entries{cache="${escapeLabelValue(name)}"} ${snap.caches[name]}`);
271
- }
272
- }
273
-
274
- // HTTP request counter.
275
- lines.push(
276
- "# HELP mandu_http_requests_total Total HTTP requests served by the Mandu runtime.",
277
- "# TYPE mandu_http_requests_total counter",
278
- );
279
- const counts = getHttpRequestCounts();
280
- if (counts.length === 0) {
281
- lines.push(`mandu_http_requests_total{method="GET",status="2xx"} 0`);
282
- } else {
283
- for (const { method, statusClass, value } of counts) {
284
- lines.push(
285
- `mandu_http_requests_total{method="${escapeLabelValue(method)}",status="${escapeLabelValue(statusClass)}"} ${value}`,
286
- );
287
- }
288
- }
289
-
290
- // Final newline — the Prometheus parser is tolerant but conventional.
291
- return lines.join("\n") + "\n";
292
- }
293
-
294
- // --------------------------------------------------------------------
295
- // HTTP endpoint handlers
296
- // --------------------------------------------------------------------
297
-
298
- /** Endpoint paths used by the runtime dispatcher. */
299
- export const HEAP_ENDPOINT = "/_mandu/heap";
300
- export const METRICS_ENDPOINT = "/_mandu/metrics";
301
-
302
- /**
303
- * Gate production access. In dev (`isDev=true`) we always allow.
304
- * In prod the operator must set `MANDU_DEBUG_HEAP=1` (so scrapers
305
- * cannot trivially probe) unless an explicit config flag opted in.
306
- */
307
- export function isObservabilityExposed(isDev: boolean, configFlag: boolean | undefined): boolean {
308
- if (isDev) return configFlag !== false;
309
- if (configFlag === true) return true;
310
- return process.env.MANDU_DEBUG_HEAP === "1";
311
- }
312
-
313
- export function buildHeapResponse(): Response {
314
- const snapshot = collectHeapSnapshot();
315
- return new Response(JSON.stringify(snapshot, null, 2), {
316
- status: 200,
317
- headers: {
318
- "Content-Type": "application/json; charset=utf-8",
319
- "Cache-Control": "no-store",
320
- },
321
- });
322
- }
323
-
324
- export function buildMetricsResponse(): Response {
325
- const body = renderPrometheus();
326
- return new Response(body, {
327
- status: 200,
328
- headers: {
329
- // Prometheus text exposition format spec version 0.0.4.
330
- "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
331
- "Cache-Control": "no-store",
332
- },
333
- });
334
- }
1
+ /**
2
+ * Phase 17 — lightweight in-process metrics + heap snapshot.
3
+ *
4
+ * Two outputs are derived from the same underlying state:
5
+ *
6
+ * 1. JSON snapshot (`/_mandu/heap`) — human-oriented debug dump.
7
+ * 2. Prometheus text exposition (`/_mandu/metrics`) — scraper-friendly.
8
+ *
9
+ * Design rules:
10
+ * - Hand-rolled. No new runtime deps.
11
+ * - Zero allocation in the hot path — counters are plain numbers.
12
+ * - Cache sizes are provided through a registry so each cache lives
13
+ * in its own module (no circular imports). Call
14
+ * `registerCacheSize("patternCache", () => cache.size)` at module
15
+ * init; the metrics collector calls each reporter lazily on scrape.
16
+ * - Label cardinality is strictly bounded — HTTP request counts are
17
+ * keyed by `{method, statusClass}` where statusClass is `2xx`/`3xx`/
18
+ * `4xx`/`5xx`/`other`, not the raw status. Prevents runaway series.
19
+ */
20
+
21
+ export type CacheName = "patternCache" | "fetchCache" | "perFileTimers" | string;
22
+
23
+ /**
24
+ * Registry of cache-size reporters. Each reporter is called at scrape
25
+ * time and must return the current cache entry count. Reporters that
26
+ * throw or return a non-finite number are treated as `0` (defence
27
+ * against a half-torn-down subsystem).
28
+ */
29
+ const cacheSizeReporters = new Map<CacheName, () => number>();
30
+
31
+ /**
32
+ * Register (or replace) a cache-size reporter. Callers typically wire
33
+ * this at module init:
34
+ *
35
+ * const cache = new LRUCache<string, Compiled>({ maxSize: 200 });
36
+ * registerCacheSize("patternCache", () => cache.size);
37
+ */
38
+ export function registerCacheSize(name: CacheName, reporter: () => number): void {
39
+ cacheSizeReporters.set(name, reporter);
40
+ }
41
+
42
+ /**
43
+ * Unregister a reporter. Used by hot-reload paths that rebuild their
44
+ * cache under a fresh reference.
45
+ */
46
+ export function unregisterCacheSize(name: CacheName): boolean {
47
+ return cacheSizeReporters.delete(name);
48
+ }
49
+
50
+ /**
51
+ * For tests — drop every reporter. Production code should never need this.
52
+ */
53
+ export function clearCacheSizeReporters(): void {
54
+ cacheSizeReporters.clear();
55
+ }
56
+
57
+ /**
58
+ * Collect current sizes. Missing reporters simply don't appear. A
59
+ * thrown / non-finite reporter contributes `0` and is silently logged
60
+ * on the event bus (best-effort — we never propagate).
61
+ */
62
+ export function collectCacheSizes(): Record<string, number> {
63
+ const out: Record<string, number> = {};
64
+ for (const [name, reporter] of cacheSizeReporters) {
65
+ let size = 0;
66
+ try {
67
+ const v = reporter();
68
+ size = typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0;
69
+ } catch {
70
+ size = 0;
71
+ }
72
+ out[name] = size;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // --------------------------------------------------------------------
78
+ // HTTP request counter
79
+ // --------------------------------------------------------------------
80
+
81
+ /**
82
+ * `Map` keyed by `"METHOD statusClass"` for bounded cardinality.
83
+ * E.g. `"GET 2xx"` → 41.
84
+ */
85
+ const httpRequestCounter = new Map<string, number>();
86
+
87
+ const STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx", "other"] as const;
88
+ type StatusClass = (typeof STATUS_CLASSES)[number];
89
+
90
+ function classifyStatus(status: number): StatusClass {
91
+ if (status >= 200 && status < 300) return "2xx";
92
+ if (status >= 300 && status < 400) return "3xx";
93
+ if (status >= 400 && status < 500) return "4xx";
94
+ if (status >= 500 && status < 600) return "5xx";
95
+ return "other";
96
+ }
97
+
98
+ function normalizeMethod(method: string | undefined): string {
99
+ if (!method) return "UNKNOWN";
100
+ const upper = method.toUpperCase();
101
+ // Whitelist standard methods so a rogue "evil\n" header can't break
102
+ // the Prometheus line format. Anything unknown bucket under OTHER.
103
+ const allowed = new Set([
104
+ "GET",
105
+ "HEAD",
106
+ "POST",
107
+ "PUT",
108
+ "DELETE",
109
+ "PATCH",
110
+ "OPTIONS",
111
+ "TRACE",
112
+ "CONNECT",
113
+ ]);
114
+ return allowed.has(upper) ? upper : "OTHER";
115
+ }
116
+
117
+ /**
118
+ * Bump the request counter. Safe to call on every request path — O(1).
119
+ * The method/status pair is normalised to a bounded cardinality set.
120
+ */
121
+ export function recordHttpRequest(method: string | undefined, status: number): void {
122
+ const m = normalizeMethod(method);
123
+ const cls = classifyStatus(status);
124
+ const key = `${m} ${cls}`;
125
+ httpRequestCounter.set(key, (httpRequestCounter.get(key) ?? 0) + 1);
126
+ }
127
+
128
+ /** Reset counters. Used by tests; prod rarely needs this. */
129
+ export function resetHttpRequestCounter(): void {
130
+ httpRequestCounter.clear();
131
+ }
132
+
133
+ /** Snapshot current counts for programmatic inspection. */
134
+ export function getHttpRequestCounts(): Array<{ method: string; statusClass: StatusClass; value: number }> {
135
+ const out: Array<{ method: string; statusClass: StatusClass; value: number }> = [];
136
+ for (const [key, value] of httpRequestCounter) {
137
+ const [method, statusClass] = key.split(" ");
138
+ out.push({ method: method!, statusClass: statusClass as StatusClass, value });
139
+ }
140
+ // Stable order → deterministic Prometheus output (aids scraping + tests).
141
+ out.sort((a, b) => {
142
+ if (a.method !== b.method) return a.method < b.method ? -1 : 1;
143
+ return a.statusClass < b.statusClass ? -1 : 1;
144
+ });
145
+ return out;
146
+ }
147
+
148
+ // --------------------------------------------------------------------
149
+ // Heap snapshot
150
+ // --------------------------------------------------------------------
151
+
152
+ export interface HeapSnapshot {
153
+ /** Unix epoch millis when snapshot was captured. */
154
+ timestamp: number;
155
+ /** Process uptime in seconds. */
156
+ uptime: number;
157
+ /** `process.memoryUsage()` — always available. */
158
+ process: {
159
+ rss: number;
160
+ heapTotal: number;
161
+ heapUsed: number;
162
+ external: number;
163
+ arrayBuffers: number;
164
+ };
165
+ /** `Bun.memoryUsage()` if running on Bun and the API is available. */
166
+ bun?: Record<string, number>;
167
+ /** Current cache entry counts, keyed by reporter name. */
168
+ caches: Record<string, number>;
169
+ }
170
+
171
+ /**
172
+ * Assemble a heap snapshot. This is the single source of truth for
173
+ * both the JSON endpoint and the Prometheus exporter.
174
+ */
175
+ export function collectHeapSnapshot(): HeapSnapshot {
176
+ const mem = process.memoryUsage();
177
+ const snapshot: HeapSnapshot = {
178
+ timestamp: Date.now(),
179
+ uptime: process.uptime(),
180
+ process: {
181
+ rss: mem.rss,
182
+ heapTotal: mem.heapTotal,
183
+ heapUsed: mem.heapUsed,
184
+ external: mem.external,
185
+ arrayBuffers: mem.arrayBuffers ?? 0,
186
+ },
187
+ caches: collectCacheSizes(),
188
+ };
189
+
190
+ // Bun.memoryUsage() is currently a non-standard helper; we feature-
191
+ // detect to stay forward-compatible with other runtimes (Node tests,
192
+ // edge workers) where the global is absent.
193
+ const bunGlobal = (globalThis as { Bun?: { memoryUsage?: () => Record<string, number> } }).Bun;
194
+ if (bunGlobal?.memoryUsage) {
195
+ try {
196
+ const bunMem = bunGlobal.memoryUsage();
197
+ if (bunMem && typeof bunMem === "object") {
198
+ snapshot.bun = bunMem;
199
+ }
200
+ } catch {
201
+ // Swallow — not every Bun version ships this.
202
+ }
203
+ }
204
+
205
+ return snapshot;
206
+ }
207
+
208
+ // --------------------------------------------------------------------
209
+ // Prometheus text exposition
210
+ // --------------------------------------------------------------------
211
+
212
+ /**
213
+ * Escape a label value per Prometheus text format §
214
+ *
215
+ * - backslash → `\\`
216
+ * - newline → `\n`
217
+ * - double-quote → `\"`
218
+ *
219
+ * We never inline tab/CR because `normalizeMethod` already clamps
220
+ * methods to uppercase ASCII and statusClass is a fixed enum.
221
+ */
222
+ function escapeLabelValue(value: string): string {
223
+ return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
224
+ }
225
+
226
+ /**
227
+ * Render the current metric state in Prometheus text format. Each
228
+ * metric starts with `# HELP` + `# TYPE` headers per the spec.
229
+ *
230
+ * The output is deterministic (sorted labels, stable order) so tests
231
+ * can snapshot against it and scrapers get a consistent diff.
232
+ */
233
+ export function renderPrometheus(snapshot?: HeapSnapshot): string {
234
+ const snap = snapshot ?? collectHeapSnapshot();
235
+ const lines: string[] = [];
236
+
237
+ // Node/Bun heap gauges.
238
+ lines.push(
239
+ "# HELP nodejs_heap_used_bytes Process heap used in bytes (process.memoryUsage().heapUsed).",
240
+ "# TYPE nodejs_heap_used_bytes gauge",
241
+ `nodejs_heap_used_bytes ${snap.process.heapUsed}`,
242
+ "# HELP nodejs_heap_total_bytes Process heap total in bytes (process.memoryUsage().heapTotal).",
243
+ "# TYPE nodejs_heap_total_bytes gauge",
244
+ `nodejs_heap_total_bytes ${snap.process.heapTotal}`,
245
+ "# HELP nodejs_external_bytes Process external memory in bytes (process.memoryUsage().external).",
246
+ "# TYPE nodejs_external_bytes gauge",
247
+ `nodejs_external_bytes ${snap.process.external}`,
248
+ "# HELP nodejs_rss_bytes Process resident set size in bytes.",
249
+ "# TYPE nodejs_rss_bytes gauge",
250
+ `nodejs_rss_bytes ${snap.process.rss}`,
251
+ "# HELP nodejs_uptime_seconds Process uptime in seconds.",
252
+ "# TYPE nodejs_uptime_seconds gauge",
253
+ `nodejs_uptime_seconds ${snap.uptime.toFixed(3)}`,
254
+ );
255
+
256
+ // Cache entry counts — one line per registered reporter. Keys are
257
+ // emitted in sorted order so the output is stable across scrapes.
258
+ lines.push(
259
+ "# HELP mandu_cache_entries Current entry count for Mandu internal caches.",
260
+ "# TYPE mandu_cache_entries gauge",
261
+ );
262
+ const cacheNames = Object.keys(snap.caches).sort();
263
+ if (cacheNames.length === 0) {
264
+ // Prometheus requires at least one sample for the series to be
265
+ // useful; emit a zero-valued placeholder so scrapers don't drop
266
+ // the metric entirely on an empty registry.
267
+ lines.push(`mandu_cache_entries{cache="none"} 0`);
268
+ } else {
269
+ for (const name of cacheNames) {
270
+ lines.push(`mandu_cache_entries{cache="${escapeLabelValue(name)}"} ${snap.caches[name]}`);
271
+ }
272
+ }
273
+
274
+ // HTTP request counter.
275
+ lines.push(
276
+ "# HELP mandu_http_requests_total Total HTTP requests served by the Mandu runtime.",
277
+ "# TYPE mandu_http_requests_total counter",
278
+ );
279
+ const counts = getHttpRequestCounts();
280
+ if (counts.length === 0) {
281
+ lines.push(`mandu_http_requests_total{method="GET",status="2xx"} 0`);
282
+ } else {
283
+ for (const { method, statusClass, value } of counts) {
284
+ lines.push(
285
+ `mandu_http_requests_total{method="${escapeLabelValue(method)}",status="${escapeLabelValue(statusClass)}"} ${value}`,
286
+ );
287
+ }
288
+ }
289
+
290
+ // Final newline — the Prometheus parser is tolerant but conventional.
291
+ return lines.join("\n") + "\n";
292
+ }
293
+
294
+ // --------------------------------------------------------------------
295
+ // HTTP endpoint handlers
296
+ // --------------------------------------------------------------------
297
+
298
+ /** Endpoint paths used by the runtime dispatcher. */
299
+ export const HEAP_ENDPOINT = "/_mandu/heap";
300
+ export const METRICS_ENDPOINT = "/_mandu/metrics";
301
+
302
+ /**
303
+ * Gate production access. In dev (`isDev=true`) we always allow.
304
+ * In prod the operator must set `MANDU_DEBUG_HEAP=1` (so scrapers
305
+ * cannot trivially probe) unless an explicit config flag opted in.
306
+ */
307
+ export function isObservabilityExposed(isDev: boolean, configFlag: boolean | undefined): boolean {
308
+ if (isDev) return configFlag !== false;
309
+ if (configFlag === true) return true;
310
+ return process.env.MANDU_DEBUG_HEAP === "1";
311
+ }
312
+
313
+ export function buildHeapResponse(): Response {
314
+ const snapshot = collectHeapSnapshot();
315
+ return new Response(JSON.stringify(snapshot, null, 2), {
316
+ status: 200,
317
+ headers: {
318
+ "Content-Type": "application/json; charset=utf-8",
319
+ "Cache-Control": "no-store",
320
+ },
321
+ });
322
+ }
323
+
324
+ export function buildMetricsResponse(): Response {
325
+ const body = renderPrometheus();
326
+ return new Response(body, {
327
+ status: 200,
328
+ headers: {
329
+ // Prometheus text exposition format spec version 0.0.4.
330
+ "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
331
+ "Cache-Control": "no-store",
332
+ },
333
+ });
334
+ }