@mandujs/core 0.24.0 → 0.25.1

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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Slug Utility (Issue #199)
3
+ *
4
+ * Converts a file path (relative to a collection root) into a stable
5
+ * URL-safe slug. This powers `collection.get(slug)` lookups and the
6
+ * default href generation in `generateSidebar()`.
7
+ *
8
+ * # Algorithm
9
+ *
10
+ * 1. Normalize separators: Windows backslashes → forward slashes so
11
+ * slugs are portable across platforms
12
+ * 2. Strip the file extension
13
+ * 3. Drop an `index` basename: `docs/intro/index.md` → `docs/intro`
14
+ * so authors can use index-style directories without a dangling
15
+ * `/index` suffix in URLs
16
+ * 4. Optional: kebab-case each segment (default ON) so file names
17
+ * like `Getting_Started.md` produce `getting-started`
18
+ * 5. Remove any consecutive slashes and leading/trailing slashes
19
+ */
20
+
21
+ /** Options controlling slug generation. */
22
+ export interface SlugFromPathOptions {
23
+ /**
24
+ * When true (default), every path segment is lower-cased and
25
+ * underscores/camelCase boundaries are converted to dashes.
26
+ * Disable this when a project's authoring convention requires
27
+ * preserving source file naming — in which case only the
28
+ * extension and `/index` suffix are stripped.
29
+ */
30
+ kebabCase?: boolean;
31
+ /**
32
+ * Extra extensions to strip, in addition to the defaults
33
+ * (`.md`, `.mdx`, `.markdown`). Caller-supplied values should
34
+ * include the leading dot.
35
+ */
36
+ stripExtensions?: string[];
37
+ /**
38
+ * Override the default "drop `/index` suffix" behavior. When
39
+ * false, `foo/index.md` becomes `foo/index` instead of `foo`.
40
+ */
41
+ dropIndex?: boolean;
42
+ }
43
+
44
+ const DEFAULT_EXTS = new Set([".md", ".mdx", ".markdown"]);
45
+
46
+ /**
47
+ * Convert a collection-relative path to a URL slug.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * slugFromPath("getting-started/install.md") // "getting-started/install"
52
+ * slugFromPath("Intro\\Welcome.md") // "intro/welcome"
53
+ * slugFromPath("api/index.md") // "api"
54
+ * slugFromPath("API_v2.md", { kebabCase: false }) // "API_v2"
55
+ * ```
56
+ */
57
+ export function slugFromPath(
58
+ filePath: string,
59
+ options: SlugFromPathOptions = {}
60
+ ): string {
61
+ const { kebabCase = true, stripExtensions, dropIndex = true } = options;
62
+ const allExts = new Set<string>(DEFAULT_EXTS);
63
+ if (stripExtensions) {
64
+ for (const ext of stripExtensions) allExts.add(ext);
65
+ }
66
+
67
+ // Normalize separators first so downstream logic only deals with `/`.
68
+ let out = filePath.replace(/\\/g, "/").trim();
69
+
70
+ // Strip any matching extension from the tail. We only strip ONE —
71
+ // files with double extensions (`foo.test.md`) keep the `.test`
72
+ // segment on purpose.
73
+ const dotIdx = out.lastIndexOf(".");
74
+ const slashIdx = out.lastIndexOf("/");
75
+ if (dotIdx > slashIdx) {
76
+ const ext = out.slice(dotIdx);
77
+ if (allExts.has(ext.toLowerCase())) {
78
+ out = out.slice(0, dotIdx);
79
+ }
80
+ }
81
+
82
+ // Drop `/index` suffix or a bare `index` basename.
83
+ if (dropIndex) {
84
+ if (out === "index") {
85
+ out = "";
86
+ } else if (out.endsWith("/index")) {
87
+ out = out.slice(0, -"/index".length);
88
+ }
89
+ }
90
+
91
+ // kebab-case per segment so existing slashes survive.
92
+ if (kebabCase) {
93
+ out = out
94
+ .split("/")
95
+ .map((seg) =>
96
+ seg
97
+ // Insert dash between camelCase: fooBar → foo-Bar (later lower-cased)
98
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
99
+ .replace(/[_\s]+/g, "-")
100
+ .replace(/--+/g, "-")
101
+ .toLowerCase()
102
+ )
103
+ .join("/");
104
+ }
105
+
106
+ // Collapse duplicate slashes and strip boundary slashes.
107
+ out = out.replace(/\/+/g, "/").replace(/^\/+|\/+$/g, "");
108
+
109
+ return out;
110
+ }
@@ -119,9 +119,12 @@ export async function checkInvalidGeneratedImport(
119
119
  violations.push({
120
120
  ruleId: GUARD_RULES.INVALID_GENERATED_IMPORT.id,
121
121
  file: relativePath,
122
- message: `generated 파일 직접 import 금지: ${match[1]}`,
122
+ message:
123
+ `Direct __generated__/ imports are forbidden: ${match[1]}. ` +
124
+ `Use the runtime registry: see https://mandujs.com/docs/architect/generated-access`,
123
125
  suggestion:
124
- "generated 파일을 직접 import하지 말고, 런타임 레지스트리를 통해 접근하세요",
126
+ "Import getGenerated() from @mandujs/core/runtime and read the generated artifact through the manifest. " +
127
+ "See https://mandujs.com/docs/architect/generated-access for the decision tree.",
125
128
  });
126
129
  }
127
130
  }
@@ -1,18 +1,37 @@
1
- export {
2
- eventBus,
3
- type ObservabilityEvent,
4
- type EventType,
5
- type ObservabilitySeverity,
6
- type EventHandler,
7
- } from "./event-bus";
8
- export { connectLoggerToEventBus } from "./logger-adapter";
9
- // Phase 6: SQLite 영구 저장 + 시계열 쿼리
10
- export {
11
- startSqliteStore,
12
- stopSqliteStore,
13
- queryEvents,
14
- queryStats,
15
- exportJsonl,
16
- exportOtlp,
17
- type QueryOptions,
18
- } from "./sqlite-store";
1
+ export {
2
+ eventBus,
3
+ type ObservabilityEvent,
4
+ type EventType,
5
+ type ObservabilitySeverity,
6
+ type EventHandler,
7
+ } from "./event-bus";
8
+ export { connectLoggerToEventBus } from "./logger-adapter";
9
+ // Phase 6: SQLite 영구 저장 + 시계열 쿼리
10
+ export {
11
+ startSqliteStore,
12
+ stopSqliteStore,
13
+ queryEvents,
14
+ queryStats,
15
+ exportJsonl,
16
+ exportOtlp,
17
+ type QueryOptions,
18
+ } from "./sqlite-store";
19
+ // Phase 17: heap endpoint + Prometheus metrics
20
+ export {
21
+ registerCacheSize,
22
+ unregisterCacheSize,
23
+ clearCacheSizeReporters,
24
+ collectCacheSizes,
25
+ recordHttpRequest,
26
+ resetHttpRequestCounter,
27
+ getHttpRequestCounts,
28
+ collectHeapSnapshot,
29
+ renderPrometheus,
30
+ isObservabilityExposed,
31
+ buildHeapResponse,
32
+ buildMetricsResponse,
33
+ HEAP_ENDPOINT,
34
+ METRICS_ENDPOINT,
35
+ type HeapSnapshot,
36
+ type CacheName,
37
+ } from "./metrics";
@@ -0,0 +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
+ }
@@ -29,3 +29,14 @@ export { type MiddlewareContext, type MiddlewareNext, type MiddlewareFn, type Mi
29
29
  export { type ManduAdapter, type AdapterOptions, type AdapterServer } from "./adapter";
30
30
  export { adapterBun } from "./adapter-bun";
31
31
  export { createFetchHandler, type FetchHandlerOptions } from "./handler";
32
+ export {
33
+ getGenerated,
34
+ tryGetGenerated,
35
+ getManifest,
36
+ getRouteById,
37
+ registerManifest,
38
+ clearGeneratedRegistry,
39
+ type GeneratedRegistry,
40
+ type GeneratedKey,
41
+ type GeneratedShape,
42
+ } from "./registry";