@mandujs/core 0.29.0 → 0.30.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.
@@ -170,6 +170,16 @@ export interface ManduConfig {
170
170
  minify?: boolean;
171
171
  sourcemap?: boolean;
172
172
  splitting?: boolean;
173
+ /**
174
+ * Phase 18.η — emit `.mandu/analyze/report.html` + `report.json` after
175
+ * a successful build. Equivalent to `mandu build --analyze`. Default:
176
+ * `false`. Report artefacts are self-contained (no CDN, no external
177
+ * JS) and safe to commit to a private dashboard or inspect locally.
178
+ *
179
+ * The CLI `--analyze` flag wins over this field; `--analyze=json`
180
+ * writes JSON only (useful for CI, skips the HTML render cost).
181
+ */
182
+ analyze?: boolean;
173
183
  };
174
184
  dev?: {
175
185
  hmr?: boolean;
@@ -286,6 +296,64 @@ export interface ManduConfig {
286
296
  heapEndpoint?: boolean;
287
297
  /** `/_mandu/metrics` Prometheus text exposure toggle. */
288
298
  metricsEndpoint?: boolean;
299
+ /**
300
+ * Phase 18.θ — OpenTelemetry-compatible request tracing.
301
+ *
302
+ * When enabled, every request opens a root server span
303
+ * (`http.request`) that chains child spans for middleware, loader,
304
+ * SSR, and sandbox execution. The root span's trace-id is
305
+ * propagated across AsyncLocalStorage and stamped onto outgoing
306
+ * fetches via `traceparent`.
307
+ *
308
+ * Exporters:
309
+ * - `"console"` (default) — pretty-prints spans to stderr, useful
310
+ * in `mandu dev`.
311
+ * - `"otlp"` — POSTs OTLP/HTTP JSON to `endpoint/v1/traces`.
312
+ * Compatible with Honeycomb, Grafana Tempo, AWS X-Ray (via the
313
+ * OTel Collector), and the standalone OpenTelemetry Collector.
314
+ *
315
+ * Setting the `MANDU_OTEL_ENDPOINT` env var overrides both
316
+ * `enabled` and `exporter` at runtime (shortcut for ops that want
317
+ * to enable tracing without a config change).
318
+ *
319
+ * Default: disabled (zero overhead when `observability.tracing` is
320
+ * omitted).
321
+ */
322
+ tracing?: {
323
+ enabled?: boolean;
324
+ exporter?: "console" | "otlp";
325
+ endpoint?: string;
326
+ headers?: Record<string, string>;
327
+ serviceName?: string;
328
+ };
329
+ };
330
+ /**
331
+ * Phase 18.ζ — ISR / tag-based cache invalidation.
332
+ *
333
+ * - `defaultMaxAge` : fresh TTL (seconds) applied when a loader
334
+ * does not emit its own `_cache` / `ctx.cache`
335
+ * metadata. Set to a positive integer to enable
336
+ * automatic caching across every non-dynamic
337
+ * route (Next.js `export const revalidate`
338
+ * equivalent). Default `undefined` (no auto).
339
+ * - `defaultSwr` : stale-while-revalidate window (seconds)
340
+ * appended after the fresh TTL. Serves stale
341
+ * HTML instantly while background regeneration
342
+ * runs. Default `0`.
343
+ * - `maxEntries` : LRU bound for the in-memory store. Default
344
+ * `1000`.
345
+ * - `store` : backend. Currently `"memory"` only; reserved
346
+ * for future `"redis"` adapter.
347
+ *
348
+ * Disable entirely by omitting this block. Revalidation APIs live in
349
+ * `@mandujs/core/runtime`: `revalidate(tag)`, `revalidateTag(tag)`,
350
+ * `revalidatePath(path)`.
351
+ */
352
+ cache?: {
353
+ defaultMaxAge?: number;
354
+ defaultSwr?: number;
355
+ maxEntries?: number;
356
+ store?: "memory";
289
357
  };
290
358
  plugins?: ManduPlugin[];
291
359
  hooks?: Partial<ManduHooks>;
@@ -105,6 +105,13 @@ const BuildConfigSchema = z
105
105
  * module exports `generateStaticParams` is prerendered).
106
106
  */
107
107
  prerender: z.boolean().default(true),
108
+ /**
109
+ * Phase 18.η — opt into post-build bundle analyzer artefacts
110
+ * (`.mandu/analyze/report.html` + `report.json`). Default `false`.
111
+ * CLI `--analyze` overrides this at runtime; config is the "always on
112
+ * for this project" switch.
113
+ */
114
+ analyze: z.boolean().default(false),
108
115
  })
109
116
  .strict();
110
117
 
@@ -229,10 +236,28 @@ const TestConfigSchema = z
229
236
  * default so the runtime can distinguish "not set" (use mode default)
230
237
  * from "explicit false" (force off even in dev).
231
238
  */
239
+ /**
240
+ * Phase 18.θ — tracing sub-block. All fields optional; omitting the
241
+ * whole block keeps tracing disabled and incurs zero runtime overhead.
242
+ * `endpoint` and `headers` are only meaningful when `exporter === "otlp"`
243
+ * but we don't make that a cross-field refine — runtime falls back to
244
+ * console with a warning when `"otlp"` is requested without an endpoint.
245
+ */
246
+ const TracingConfigSchema = z
247
+ .object({
248
+ enabled: z.boolean().optional(),
249
+ exporter: z.enum(["console", "otlp"]).optional(),
250
+ endpoint: z.string().url().optional(),
251
+ headers: z.record(z.string()).optional(),
252
+ serviceName: z.string().min(1).optional(),
253
+ })
254
+ .strict();
255
+
232
256
  const ObservabilityConfigSchema = z
233
257
  .object({
234
258
  heapEndpoint: z.boolean().optional(),
235
259
  metricsEndpoint: z.boolean().optional(),
260
+ tracing: TracingConfigSchema.optional(),
236
261
  })
237
262
  .strict();
238
263
 
@@ -293,6 +318,23 @@ const MiddlewareSchema = z.custom<Middleware>(
293
318
  }
294
319
  );
295
320
 
321
+ /**
322
+ * Phase 18.ζ — ISR / cache config (strict).
323
+ *
324
+ * All fields optional. Omitting the block leaves caching disabled unless
325
+ * individual routes opt in via `filling.loader(fn, { revalidate })` or
326
+ * loader-level `_cache` metadata. Setting `defaultMaxAge` makes every
327
+ * non-dynamic route auto-cache with that fresh TTL.
328
+ */
329
+ const CacheConfigSchema = z
330
+ .object({
331
+ defaultMaxAge: z.number().int().nonnegative().optional(),
332
+ defaultSwr: z.number().int().nonnegative().optional(),
333
+ maxEntries: z.number().int().positive().optional(),
334
+ store: z.enum(["memory"]).optional(),
335
+ })
336
+ .strict();
337
+
296
338
  export const ManduConfigSchema = z
297
339
  .object({
298
340
  adapter: AdapterConfigSchema.optional(),
@@ -322,6 +364,8 @@ export const ManduConfigSchema = z
322
364
  seo: SeoConfigSchema.default({}),
323
365
  test: TestConfigSchema.default({}),
324
366
  observability: ObservabilityConfigSchema.default({}),
367
+ /** Phase 18.ζ — ISR / tag-based cache invalidation. Optional. */
368
+ cache: CacheConfigSchema.optional(),
325
369
  plugins: z.array(ManduPluginSchema).optional(),
326
370
  hooks: ManduHooksSchema.optional(),
327
371
  /**
@@ -12,6 +12,14 @@ import { ContractValidator, type ContractValidatorOptions } from "../contract/va
12
12
  import { getCookieCodec } from "./cookie-codec";
13
13
  import { type FillingDeps, globalDeps } from "./deps";
14
14
  import { createSSEConnection, type SSEOptions, type SSEConnection } from "./sse";
15
+ import {
16
+ getActiveSpan,
17
+ getTracer,
18
+ runWithSpan,
19
+ type Span,
20
+ type SpanAttributes,
21
+ type SpanOptions,
22
+ } from "../observability/tracing";
15
23
 
16
24
  type ContractInput<
17
25
  TContract extends ContractSchema,
@@ -285,12 +293,45 @@ async function hmacSign(data: string, secret: string): Promise<string> {
285
293
 
286
294
  // ========== ManduContext ==========
287
295
 
296
+ /**
297
+ * Phase 18.ζ — 로더가 캐시 메타데이터를 선언하기 위한 플루언트 헬퍼.
298
+ *
299
+ * 내부적으로는 `ctx._cacheMeta` 에 누적하며, 서버 런타임이 loader 실행
300
+ * 직후 읽어 `_cache` 블록과 동일하게 해석한다. 반환 데이터에 `_cache` 를
301
+ * 끼워넣는 방식과 동일한 의미지만, 타입 안정성이 더 높다.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * .loader(async (ctx) => {
306
+ * ctx.cache.tag("posts").tag("posts:42").maxAge(3600).swr(86400);
307
+ * return { post: await db.getPost(42) };
308
+ * })
309
+ * ```
310
+ */
311
+ export interface CacheHelper {
312
+ tag(...tags: string[]): CacheHelper;
313
+ maxAge(seconds: number): CacheHelper;
314
+ /** alias for maxAge — Next.js parity */
315
+ revalidate(seconds: number): CacheHelper;
316
+ swr(seconds: number): CacheHelper;
317
+ /** alias for swr */
318
+ staleWhileRevalidate(seconds: number): CacheHelper;
319
+ /** 현재까지 누적된 메타데이터 스냅샷 (런타임 내부용) */
320
+ readonly meta: {
321
+ tags: string[];
322
+ maxAge?: number;
323
+ staleWhileRevalidate?: number;
324
+ };
325
+ }
326
+
288
327
  export class ManduContext {
289
328
  private store: Map<string, unknown> = new Map();
290
329
  private _params: Record<string, string>;
291
330
  private _query: Record<string, string>;
292
331
  private _cookies: CookieManager;
293
332
  private _deps: FillingDeps;
333
+ private _cacheMeta: { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } = { tags: [] };
334
+ private _cacheHelper: CacheHelper | null = null;
294
335
 
295
336
  constructor(
296
337
  public readonly request: Request,
@@ -303,6 +344,59 @@ export class ManduContext {
303
344
  this._deps = deps ?? globalDeps.get();
304
345
  }
305
346
 
347
+ /**
348
+ * Phase 18.ζ — 로더 내부에서 호출하는 캐시 메타데이터 플루언트 헬퍼.
349
+ * 누적 호출 가능: `ctx.cache.tag("a", "b").maxAge(60).swr(300)`
350
+ */
351
+ get cache(): CacheHelper {
352
+ if (this._cacheHelper) return this._cacheHelper;
353
+ const meta = this._cacheMeta;
354
+ const helper: CacheHelper = {
355
+ tag: (...tags: string[]) => {
356
+ for (const t of tags) {
357
+ if (typeof t === "string" && t.length > 0 && !meta.tags.includes(t)) {
358
+ meta.tags.push(t);
359
+ }
360
+ }
361
+ return helper;
362
+ },
363
+ maxAge: (seconds: number) => {
364
+ if (Number.isFinite(seconds) && seconds >= 0) meta.maxAge = seconds;
365
+ return helper;
366
+ },
367
+ revalidate: (seconds: number) => helper.maxAge(seconds),
368
+ swr: (seconds: number) => {
369
+ if (Number.isFinite(seconds) && seconds >= 0) meta.staleWhileRevalidate = seconds;
370
+ return helper;
371
+ },
372
+ staleWhileRevalidate: (seconds: number) => helper.swr(seconds),
373
+ get meta() {
374
+ return {
375
+ tags: [...meta.tags],
376
+ maxAge: meta.maxAge,
377
+ staleWhileRevalidate: meta.staleWhileRevalidate,
378
+ };
379
+ },
380
+ };
381
+ this._cacheHelper = helper;
382
+ return helper;
383
+ }
384
+
385
+ /**
386
+ * Phase 18.ζ — 서버 런타임이 loader 종료 후 누적된 메타 스냅샷을 읽는다.
387
+ * 내부 전용. 유저 코드는 `ctx.cache` 를 사용할 것.
388
+ */
389
+ getCacheMetaSnapshot(): { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } | null {
390
+ if (this._cacheMeta.tags.length === 0 && this._cacheMeta.maxAge === undefined && this._cacheMeta.staleWhileRevalidate === undefined) {
391
+ return null;
392
+ }
393
+ return {
394
+ tags: [...this._cacheMeta.tags],
395
+ maxAge: this._cacheMeta.maxAge,
396
+ staleWhileRevalidate: this._cacheMeta.staleWhileRevalidate,
397
+ };
398
+ }
399
+
306
400
  /**
307
401
  * DNA-002: 의존성 접근
308
402
  *
@@ -631,6 +725,78 @@ export class ManduContext {
631
725
  has(key: string): boolean {
632
726
  return this.store.has(key);
633
727
  }
728
+
729
+ // ============================================
730
+ // 🥟 Tracing (Phase 18.θ)
731
+ // ============================================
732
+
733
+ /**
734
+ * The currently-active tracing span for this request, or `undefined`
735
+ * when tracing is disabled. Looked up via AsyncLocalStorage so it
736
+ * stays accurate across nested awaits — a loader that opens a child
737
+ * span before hitting an external API will see `ctx.span` return
738
+ * that child span until the child's `end()` fires.
739
+ *
740
+ * When tracing is disabled this reads as `undefined` (the runtime
741
+ * never opens a recording span), keeping the hot path free of
742
+ * extra allocations.
743
+ */
744
+ get span(): Span | undefined {
745
+ const active = getActiveSpan();
746
+ return active && active.recording ? active : undefined;
747
+ }
748
+
749
+ /**
750
+ * Open a child span scoped to the async context of `fn`. The span
751
+ * auto-ends when the returned promise resolves; if `fn` throws, the
752
+ * span is marked `status=error` with the thrown message, the span
753
+ * is ended, and the error re-thrown.
754
+ *
755
+ * When tracing is disabled this degenerates to just `await fn(noopSpan)`
756
+ * with no allocation in the hot path — callers don't have to branch
757
+ * on `ctx.span` themselves.
758
+ *
759
+ * @example
760
+ * const users = await ctx.startSpan("db.users.findAll", async (span) => {
761
+ * span.setAttribute("db.system", "postgres");
762
+ * return await sql\`SELECT * FROM users\`;
763
+ * });
764
+ */
765
+ async startSpan<T>(
766
+ name: string,
767
+ fn: (span: Span) => Promise<T> | T,
768
+ opts: SpanOptions = {}
769
+ ): Promise<T> {
770
+ const tracer = getTracer();
771
+ if (!tracer.enabled) {
772
+ // Fast path: invoke fn with a shared no-op span (type-shape
773
+ // identical, zero allocations).
774
+ const { startSpan } = tracer;
775
+ const span = startSpan.call(tracer, name, opts);
776
+ return await fn(span);
777
+ }
778
+ const span = tracer.startSpan(name, opts);
779
+ try {
780
+ const result = await runWithSpan(span, () => fn(span));
781
+ if (span.status === "unset") span.setStatus("ok");
782
+ return result;
783
+ } catch (err) {
784
+ const msg = err instanceof Error ? err.message : String(err);
785
+ span.setStatus("error", msg);
786
+ throw err;
787
+ } finally {
788
+ span.end();
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Set attributes on the active request span (if any). Convenience
794
+ * wrapper so user code doesn't have to null-check `ctx.span`
795
+ * themselves.
796
+ */
797
+ setSpanAttributes(attrs: SpanAttributes): void {
798
+ this.span?.setAttributes(attrs);
799
+ }
634
800
  }
635
801
 
636
802
  /** Route context for error reporting */
@@ -72,8 +72,17 @@ export interface LoaderOptions<T = unknown> {
72
72
 
73
73
  /** Loader 캐시/ISR 옵션 */
74
74
  export interface LoaderCacheOptions {
75
- /** 캐시 유지 시간 (초). 0이면 캐시 안 함, Infinity면 영구 */
75
+ /**
76
+ * Fresh 캐시 유지 시간 (초). `maxAge` 의 별칭 — Next.js `revalidate`
77
+ * API 와 호환성을 유지한다. 0 이면 캐시 안 함.
78
+ */
76
79
  revalidate?: number;
80
+ /**
81
+ * Phase 18.ζ — fresh 창을 지난 뒤 캐시를 계속 서빙할 stale-while-revalidate
82
+ * 창 길이 (초). 지정 시 `revalidate` 구간 후 이 기간만큼 STALE 응답을
83
+ * 즉시 반환하고 백그라운드에서 재생성한다.
84
+ */
85
+ staleWhileRevalidate?: number;
77
86
  /** 온디맨드 무효화 태그 */
78
87
  tags?: string[];
79
88
  }
package/src/index.ts CHANGED
@@ -38,6 +38,38 @@ export { type GuardViolation } from "./guard";
38
38
  export { type Severity } from "./guard";
39
39
  export { Image, type ImageProps } from "./components/Image";
40
40
 
41
+ // Phase 18.θ — `Tracer` is exported by both `runtime/trace.ts` (the
42
+ // legacy lifecycle trace collector) and `observability/tracing.ts` (the
43
+ // new OTel tracer). `export *` from both yields TS2308 ambiguity, so
44
+ // explicitly re-export the new one as canonical. The legacy runtime
45
+ // tracer remains accessible via the `runtime/trace` subpath export
46
+ // (`createTracer`, `TraceEvent`, etc. are unchanged).
47
+ export {
48
+ Tracer,
49
+ ConsoleSpanExporter,
50
+ OtlpHttpSpanExporter,
51
+ encodeOtlpJson,
52
+ parseTraceparent,
53
+ formatTraceparent,
54
+ newTraceId,
55
+ newSpanId,
56
+ getActiveSpan,
57
+ runWithSpan,
58
+ injectTraceContext,
59
+ getTracer,
60
+ setTracer,
61
+ resetTracer,
62
+ createTracerFromConfig,
63
+ type Span,
64
+ type SpanAttributes,
65
+ type SpanExporter,
66
+ type SpanKind,
67
+ type SpanOptions,
68
+ type SpanStatus,
69
+ type TraceparentFields,
70
+ type TracerConfig,
71
+ } from "./observability/tracing";
72
+
41
73
  // Consolidated Mandu namespace
42
74
  import { ManduFilling, ManduContext, ManduFillingFactory, createSSEConnection } from "./filling";
43
75
  import { createContract, defineHandler, defineRoute, createClient, contractFetch, createClientContract, querySchema, bodySchema, apiError } from "./contract";
@@ -35,3 +35,29 @@ export {
35
35
  type HeapSnapshot,
36
36
  type CacheName,
37
37
  } from "./metrics";
38
+ // Phase 18.θ: OpenTelemetry-compatible request tracing
39
+ export {
40
+ Tracer,
41
+ ConsoleSpanExporter,
42
+ OtlpHttpSpanExporter,
43
+ encodeOtlpJson,
44
+ parseTraceparent,
45
+ formatTraceparent,
46
+ newTraceId,
47
+ newSpanId,
48
+ getActiveSpan,
49
+ runWithSpan,
50
+ injectTraceContext,
51
+ getTracer,
52
+ setTracer,
53
+ resetTracer,
54
+ createTracerFromConfig,
55
+ type Span,
56
+ type SpanAttributes,
57
+ type SpanExporter,
58
+ type SpanKind,
59
+ type SpanOptions,
60
+ type SpanStatus,
61
+ type TraceparentFields,
62
+ type TracerConfig,
63
+ } from "./tracing";