@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.
@@ -22,12 +22,17 @@ import {
22
22
  type CacheStore,
23
23
  type CacheStoreStats,
24
24
  type CacheLookupResult,
25
+ type CacheConfig,
25
26
  MemoryCacheStore,
26
27
  lookupCache,
27
28
  createCacheEntry,
28
29
  createCachedResponse,
30
+ computeCacheControl,
29
31
  getCacheStoreStats,
30
32
  setGlobalCache,
33
+ setGlobalCacheDefaults,
34
+ getGlobalCacheDefaults,
35
+ createCacheStoreFromConfig,
31
36
  } from "./cache";
32
37
  import {
33
38
  createNotFoundResponse,
@@ -57,6 +62,16 @@ import {
57
62
  isObservabilityExposed,
58
63
  recordHttpRequest,
59
64
  } from "../observability/metrics";
65
+ // Phase 18.θ — request tracing. Tracer lifecycle is owned by
66
+ // `startServer()`; `runWithSpan` is used at the absolute TOP of the
67
+ // request handler so every downstream await (middleware, filling
68
+ // loader, SSR render) inherits the active span via AsyncLocalStorage.
69
+ import {
70
+ Tracer,
71
+ createTracerFromConfig,
72
+ runWithSpan,
73
+ setTracer,
74
+ } from "../observability/tracing";
60
75
  import {
61
76
  type MiddlewareFn,
62
77
  type MiddlewareConfig,
@@ -367,12 +382,17 @@ export interface ServerOptions {
367
382
  */
368
383
  guardConfig?: import("../guard/types").GuardConfig | null;
369
384
  /**
370
- * SSR 캐시 설정 (ISR/SWR 용)
371
- * - true: 기본 메모리 캐시 (LRU 1000 엔트리)
372
- * - CacheStore: 커스텀 캐시 구현체
373
- * - false/undefined: 캐시 비활성화
385
+ * SSR 캐시 설정 (ISR/SWR 용).
386
+ *
387
+ * Phase 18.ζ `CacheConfig` 객체를 추가로 지원한다.
388
+ * - `true` : 기본 메모리 캐시 (LRU 1000 엔트리)
389
+ * - `CacheStore` : 커스텀 캐시 구현체 (e.g. redis 어댑터)
390
+ * - `CacheConfig` : `{ defaultMaxAge, defaultSwr, maxEntries, store }`
391
+ * 전역 기본값을 설정 — loader 가 `_cache` 를 안 내도
392
+ * 자동으로 캐싱됨 (Next.js `export const revalidate` 등가).
393
+ * - `false`/undefined : 캐시 비활성화
374
394
  */
375
- cache?: boolean | CacheStore;
395
+ cache?: boolean | CacheStore | CacheConfig;
376
396
  /**
377
397
  * Internal management token for local CLI/runtime control endpoints.
378
398
  * When set, token-protected endpoints such as `/_mandu/cache` become available.
@@ -429,6 +449,22 @@ export interface ServerOptions {
429
449
  observability?: {
430
450
  heapEndpoint?: boolean;
431
451
  metricsEndpoint?: boolean;
452
+ /**
453
+ * Phase 18.θ — OpenTelemetry-compatible request tracing. See
454
+ * `@mandujs/core/observability` {@link import("../observability/tracing").TracerConfig}
455
+ * for field semantics. `undefined` leaves tracing disabled;
456
+ * `{ enabled: true }` opens a root span for every request and
457
+ * propagates the trace context across `await`s via
458
+ * AsyncLocalStorage. The `MANDU_OTEL_ENDPOINT` env var forces
459
+ * tracing on with the OTLP exporter when the config is omitted.
460
+ */
461
+ tracing?: {
462
+ enabled?: boolean;
463
+ exporter?: "console" | "otlp";
464
+ endpoint?: string;
465
+ headers?: Record<string, string>;
466
+ serviceName?: string;
467
+ };
432
468
  };
433
469
  /**
434
470
  * Phase 18 — prerendered HTML pass-through (SSG).
@@ -625,6 +661,12 @@ export interface ServerRegistrySettings {
625
661
  * as `heapEndpoint`.
626
662
  */
627
663
  metricsEndpoint?: boolean;
664
+ /**
665
+ * Phase 18.θ — resolved request-tracing state. `undefined` means
666
+ * tracing is disabled (the hot path is branch-free). When set, every
667
+ * request opens a root span via `tracer.startSpanFromRequest()`.
668
+ */
669
+ tracer?: import("../observability/tracing").Tracer;
628
670
  /**
629
671
  * Phase 18 — resolved prerender pass-through state. `undefined`
630
672
  * means the feature is disabled for this server instance.
@@ -691,7 +733,7 @@ export class ServerRegistry {
691
733
  /** Kitchen dev dashboard handler (dev mode only) */
692
734
  kitchen: KitchenHandler | null = null;
693
735
  /** 라우트별 캐시 옵션 (filling.loader()의 cacheOptions에서 등록) */
694
- readonly cacheOptions: Map<string, { revalidate?: number; tags?: string[] }> = new Map();
736
+ readonly cacheOptions: Map<string, { revalidate?: number; staleWhileRevalidate?: number; tags?: string[] }> = new Map();
695
737
  /** 라우트별 렌더 모드 */
696
738
  readonly renderModes: Map<string, RenderMode> = new Map();
697
739
  /** Layout slot 파일 경로 캐시 (모듈 경로 → slot 경로 | null) */
@@ -1429,6 +1471,73 @@ async function handleRequest(req: Request, router: Router, registry: ServerRegis
1429
1471
  const requestStart = Date.now();
1430
1472
  // Phase 1-4: Correlation ID — 한 요청에서 발생하는 모든 이벤트를 추적
1431
1473
  const correlationId = req.headers.get("x-mandu-request-id") ?? newId();
1474
+
1475
+ // ─── Phase 18.θ — TRACING wrap (absolute TOP of request handler) ─────────
1476
+ // Opens a root server span BEFORE γ's prerendered check, BEFORE ζ's
1477
+ // cache check, BEFORE any other Phase 18 logic. The span is bound to
1478
+ // AsyncLocalStorage so every downstream `await` (middleware chain,
1479
+ // filling loader, SSR render, sandbox exec) inherits it transparently
1480
+ // via `getActiveSpan()` / `ctx.span`.
1481
+ //
1482
+ // Parent context: honours an incoming W3C `traceparent` header so
1483
+ // upstream gateways / load balancers can correlate traces across
1484
+ // service boundaries. Missing / malformed header → a new root trace-id.
1485
+ //
1486
+ // Zero overhead when tracing is disabled: `settings.tracer` is
1487
+ // `undefined`, the `if` falls through, and we call the uninstrumented
1488
+ // path exactly as before.
1489
+ const tracer = registry.settings.tracer;
1490
+ if (tracer && tracer.enabled) {
1491
+ const url = new URL(req.url);
1492
+ const rootSpan = tracer.startSpanFromRequest("http.request", req, {
1493
+ kind: "server",
1494
+ attributes: {
1495
+ "http.method": req.method,
1496
+ "http.url": req.url,
1497
+ "http.target": url.pathname,
1498
+ "http.scheme": url.protocol.replace(":", ""),
1499
+ "http.host": url.host,
1500
+ "mandu.correlation_id": correlationId,
1501
+ },
1502
+ });
1503
+ try {
1504
+ const response = await runWithSpan(rootSpan, () =>
1505
+ handleRequestWithTracing(req, router, registry, requestStart, correlationId)
1506
+ );
1507
+ rootSpan.setAttribute("http.status_code", response.status);
1508
+ if (response.status >= 500) {
1509
+ rootSpan.setStatus("error", `HTTP ${response.status}`);
1510
+ } else if (rootSpan.status === "unset") {
1511
+ rootSpan.setStatus("ok");
1512
+ }
1513
+ return response;
1514
+ } catch (err) {
1515
+ const msg = err instanceof Error ? err.message : String(err);
1516
+ rootSpan.setStatus("error", msg);
1517
+ throw err;
1518
+ } finally {
1519
+ rootSpan.end();
1520
+ }
1521
+ }
1522
+ // ─── End Phase 18.θ ──────────────────────────────────────────────────────
1523
+
1524
+ return await handleRequestWithTracing(req, router, registry, requestStart, correlationId);
1525
+ }
1526
+
1527
+ /**
1528
+ * Phase 18.θ — inner request handler. Extracted from {@link handleRequest}
1529
+ * so the tracing wrap at the top can run the body inside a
1530
+ * `runWithSpan()` scope without a giant indent level. Preserves the
1531
+ * exact pre-tracing semantics (correlation-id logging, Cache-Control
1532
+ * stamping, eventBus emission).
1533
+ */
1534
+ async function handleRequestWithTracing(
1535
+ req: Request,
1536
+ router: Router,
1537
+ registry: ServerRegistry,
1538
+ requestStart: number,
1539
+ correlationId: string
1540
+ ): Promise<Response> {
1432
1541
  const result = await handleRequestInternal(req, router, registry);
1433
1542
 
1434
1543
  if (!result.ok) {
@@ -1629,11 +1738,24 @@ export function redactErrorForBoundary(error: Error, isDev: boolean): { error: E
1629
1738
  return { error: redacted, digest };
1630
1739
  }
1631
1740
 
1741
+ /**
1742
+ * Phase 18.ζ — per-request 로더 캐시 메타데이터. loader 반환값의 `_cache`
1743
+ * 프로퍼티 또는 `ctx.cache.*` 플루언트 호출에서 수집되어 렌더 후 캐시
1744
+ * 저장 단계에서 route-level defaults 와 merge 된다.
1745
+ */
1746
+ export interface RuntimeCacheMeta {
1747
+ tags: string[];
1748
+ maxAge?: number;
1749
+ staleWhileRevalidate?: number;
1750
+ }
1751
+
1632
1752
  interface PageLoadResult {
1633
1753
  loaderData: unknown;
1634
1754
  cookies?: CookieManager;
1635
1755
  /** Layout별 loader 데이터 (모듈 경로 → 데이터) */
1636
1756
  layoutData?: Map<string, unknown>;
1757
+ /** Phase 18.ζ — per-request 캐시 메타데이터 (_cache or ctx.cache.*) */
1758
+ cacheMeta?: RuntimeCacheMeta;
1637
1759
  /**
1638
1760
  * If the page's loader returned or threw a redirect Response, it surfaces
1639
1761
  * here. Callers short-circuit SSR and emit this Response to the browser
@@ -1656,6 +1778,71 @@ interface PageLoadResult {
1656
1778
  notFound?: Response;
1657
1779
  }
1658
1780
 
1781
+ /**
1782
+ * Phase 18.ζ — loader 반환값에서 `_cache` 블록을 분리한다.
1783
+ *
1784
+ * 반환값이 `{ _cache: {...}, ...rest }` 모양이면 `_cache` 를 추출하고
1785
+ * 나머지를 실제 loader 데이터로 돌려준다. `_cache` 형태가 잘못되면
1786
+ * 조용히 무시하여 런타임이 깨지지 않도록 한다.
1787
+ */
1788
+ function extractCacheMetaFromReturn(
1789
+ returned: unknown
1790
+ ): { data: unknown; meta: RuntimeCacheMeta | null } {
1791
+ if (
1792
+ returned &&
1793
+ typeof returned === "object" &&
1794
+ !Array.isArray(returned) &&
1795
+ "_cache" in (returned as Record<string, unknown>)
1796
+ ) {
1797
+ const obj = returned as Record<string, unknown>;
1798
+ const raw = obj._cache as Record<string, unknown> | null | undefined;
1799
+ const data = obj.data ?? (() => {
1800
+ // { _cache, ...rest } → strip _cache from the object
1801
+ const { _cache: _omit, ...rest } = obj as Record<string, unknown>;
1802
+ return rest;
1803
+ })();
1804
+ if (!raw || typeof raw !== "object") {
1805
+ return { data, meta: null };
1806
+ }
1807
+ const tagsRaw = Array.isArray(raw.tags) ? raw.tags : [];
1808
+ const tags = tagsRaw.filter((t): t is string => typeof t === "string" && t.length > 0);
1809
+ const maxAgeRaw = typeof raw.maxAge === "number"
1810
+ ? raw.maxAge
1811
+ : typeof raw.revalidate === "number"
1812
+ ? raw.revalidate
1813
+ : undefined;
1814
+ const swrRaw = typeof raw.staleWhileRevalidate === "number"
1815
+ ? raw.staleWhileRevalidate
1816
+ : typeof raw.swr === "number"
1817
+ ? raw.swr
1818
+ : undefined;
1819
+ return {
1820
+ data,
1821
+ meta: { tags, maxAge: maxAgeRaw, staleWhileRevalidate: swrRaw },
1822
+ };
1823
+ }
1824
+ return { data: returned, meta: null };
1825
+ }
1826
+
1827
+ /**
1828
+ * Phase 18.ζ — ctx.cache 스냅샷과 `_cache` 리턴 메타데이터를 병합한다.
1829
+ * 둘 다 없으면 null. 태그는 합집합, 숫자는 return 값이 우선.
1830
+ */
1831
+ function mergeRuntimeCacheMeta(
1832
+ fromReturn: RuntimeCacheMeta | null,
1833
+ fromCtx: { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } | null
1834
+ ): RuntimeCacheMeta | null {
1835
+ if (!fromReturn && !fromCtx) return null;
1836
+ const tags = new Set<string>();
1837
+ fromReturn?.tags?.forEach((t) => tags.add(t));
1838
+ fromCtx?.tags?.forEach((t) => tags.add(t));
1839
+ return {
1840
+ tags: [...tags],
1841
+ maxAge: fromReturn?.maxAge ?? fromCtx?.maxAge,
1842
+ staleWhileRevalidate: fromReturn?.staleWhileRevalidate ?? fromCtx?.staleWhileRevalidate,
1843
+ };
1844
+ }
1845
+
1659
1846
  /**
1660
1847
  * 페이지 컴포넌트 및 loader 데이터 로딩
1661
1848
  */
@@ -1710,10 +1897,16 @@ async function loadPageData(
1710
1897
  const redirectResponse = mergeCookiesIntoResponse(returned, ctx.cookies);
1711
1898
  return ok({ loaderData: undefined, redirect: redirectResponse });
1712
1899
  }
1713
- loaderData = returned;
1900
+ // Phase 18.ζ — per-request cache meta collection
1901
+ const { data: strippedData, meta: returnMeta } = extractCacheMetaFromReturn(returned);
1902
+ const mergedMeta = mergeRuntimeCacheMeta(returnMeta, ctx.getCacheMetaSnapshot());
1903
+ loaderData = strippedData;
1714
1904
  if (ctx.cookies.hasPendingCookies()) {
1715
1905
  cookies = ctx.cookies;
1716
1906
  }
1907
+ if (mergedMeta) {
1908
+ return ok({ loaderData, cookies, cacheMeta: mergedMeta });
1909
+ }
1717
1910
  }
1718
1911
  } catch (error) {
1719
1912
  const pageError = createPageLoadErrorResponse(
@@ -1816,10 +2009,16 @@ async function loadPageData(
1816
2009
  const redirectResponse = mergeCookiesIntoResponse(returned, ctx.cookies);
1817
2010
  return ok({ loaderData: undefined, redirect: redirectResponse });
1818
2011
  }
1819
- loaderData = returned;
2012
+ // Phase 18.ζ — per-request cache meta collection (legacy pageLoader path)
2013
+ const { data: strippedData, meta: returnMeta } = extractCacheMetaFromReturn(returned);
2014
+ const mergedMeta = mergeRuntimeCacheMeta(returnMeta, ctx.getCacheMetaSnapshot());
2015
+ loaderData = strippedData;
1820
2016
  if (ctx.cookies.hasPendingCookies()) {
1821
2017
  cookies = ctx.cookies;
1822
2018
  }
2019
+ if (mergedMeta) {
2020
+ return ok({ loaderData, cookies, cacheMeta: mergedMeta });
2021
+ }
1823
2022
  }
1824
2023
 
1825
2024
  return ok({ loaderData, cookies });
@@ -2536,7 +2735,19 @@ async function handlePageRoute(
2536
2735
  // Shell MISS: fall through to full render, then cache the shell below
2537
2736
  }
2538
2737
 
2539
- // ISR/SWR 캐시 확인 (SSR 렌더링 요청에만 적용)
2738
+ // ─── Phase 18.ζ ISR + cache-tags dispatch ────────────────────────────
2739
+ // Runs AFTER γ's prerendered pass-through (handled earlier in
2740
+ // `dispatchRequest`) and BEFORE full route dispatch.
2741
+ // HIT : serve fresh cache entry directly, no SSR work.
2742
+ // STALE : serve stale HTML immediately + background revalidation
2743
+ // under a per-key mutex (`pendingRevalidations`).
2744
+ // MISS : fall through to render. Save happens below (step 4b)
2745
+ // after `_cache` / `ctx.cache` metadata has been collected
2746
+ // from the loader return.
2747
+ //
2748
+ // Served responses carry `Cache-Control: public, max-age=…,
2749
+ // stale-while-revalidate=…` for CDN alignment plus the debug header
2750
+ // `X-Mandu-Cache: HIT|STALE`.
2540
2751
  if (cache && !isDataRequest && renderMode !== "dynamic" && renderMode !== "ppr") {
2541
2752
  const cacheKey = buildRouteCacheKey(route.id, url);
2542
2753
  const lookup = lookupCache(cache, cacheKey);
@@ -2563,6 +2774,7 @@ async function handlePageRoute(
2563
2774
  return ok(createCachedResponse(lookup.entry, "STALE"));
2564
2775
  }
2565
2776
  }
2777
+ // ─── End Phase 18.ζ ────────────────────────────────────────────────────
2566
2778
 
2567
2779
  // 1. 페이지 + 레이아웃 데이터 병렬 로딩
2568
2780
  const [loadResult, layoutLoad] = await Promise.all([
@@ -2671,22 +2883,66 @@ async function handlePageRoute(
2671
2883
  }).catch(() => {});
2672
2884
  }
2673
2885
 
2674
- // 4b. ISR/SWR 캐시 저장 (revalidate 설정이 있는 경우 — non-blocking)
2886
+ // ─── Phase 18.ζ — ISR/SWR cache save (step 4b) ────────────────────────
2887
+ // Resolves the effective cache metadata from three tiers (per-request
2888
+ // `_cache` / `ctx.cache` → route-level `filling.loader(fn, {...})` →
2889
+ // global `ManduConfig.cache.defaultMaxAge/defaultSwr`). When a valid
2890
+ // `maxAge > 0` is resolved, we:
2891
+ // 1. Persist the rendered HTML under the same key used by the HIT
2892
+ // path above (`buildRouteCacheKey`).
2893
+ // 2. Stamp the outgoing Response with `Cache-Control: public,
2894
+ // max-age=…, stale-while-revalidate=…` + `X-Mandu-Cache: MISS` so
2895
+ // CDNs and browsers can coordinate the same TTL we stored.
2896
+ // The persisted entry has `tags` union'd across tiers — invalidation
2897
+ // via `revalidateTag()` therefore hits both developer-declared tags
2898
+ // and per-request tags for this URL.
2675
2899
  if (cache && ssrResult.ok && renderMode !== "dynamic" && renderMode !== "ppr") {
2676
- const cacheOptions = getCacheOptionsForRoute(route.id, registry);
2677
- if (cacheOptions?.revalidate && cacheOptions.revalidate > 0) {
2900
+ const perRequest = loadResult.value.cacheMeta;
2901
+ const resolved = resolveCacheMetaForSave(route.id, registry, perRequest);
2902
+ if (resolved) {
2678
2903
  const cloned = ssrResult.value.clone();
2679
2904
  const status = ssrResult.value.status;
2680
2905
  const headers = Object.fromEntries(ssrResult.value.headers.entries());
2681
2906
  const cacheKey = buildRouteCacheKey(route.id, url);
2682
2907
  // streaming 응답도 블로킹하지 않도록 백그라운드에서 캐시 저장
2683
2908
  cloned.text().then((html) => {
2684
- cache.set(cacheKey, createCacheEntry(
2685
- html, loaderData, cacheOptions.revalidate!, cacheOptions.tags ?? [], status, headers
2686
- ));
2909
+ cache.set(
2910
+ cacheKey,
2911
+ createCacheEntry(
2912
+ html,
2913
+ loaderData,
2914
+ {
2915
+ maxAge: resolved.maxAge,
2916
+ staleWhileRevalidate: resolved.staleWhileRevalidate,
2917
+ tags: resolved.tags,
2918
+ },
2919
+ status,
2920
+ headers
2921
+ )
2922
+ );
2687
2923
  }).catch(() => {});
2924
+
2925
+ // Stamp Cache-Control + X-Mandu-Cache on the MISS response so
2926
+ // downstream CDNs honor the same TTL we just persisted.
2927
+ const freshEntryPreview = createCacheEntry(
2928
+ "",
2929
+ null,
2930
+ { maxAge: resolved.maxAge, staleWhileRevalidate: resolved.staleWhileRevalidate, tags: resolved.tags },
2931
+ status,
2932
+ {}
2933
+ );
2934
+ const cc = computeCacheControl(freshEntryPreview);
2935
+ const stamped = new Response(ssrResult.value.body, {
2936
+ status: ssrResult.value.status,
2937
+ statusText: ssrResult.value.statusText,
2938
+ headers: ssrResult.value.headers,
2939
+ });
2940
+ stamped.headers.set("Cache-Control", cc);
2941
+ stamped.headers.set("X-Mandu-Cache", "MISS");
2942
+ return ok(stamped);
2688
2943
  }
2689
2944
  }
2945
+ // ─── End Phase 18.ζ ────────────────────────────────────────────────────
2690
2946
 
2691
2947
  return ssrResult;
2692
2948
  }
@@ -2719,28 +2975,89 @@ async function regenerateCache(
2719
2975
  const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, undefined, layoutData);
2720
2976
  if (!ssrResult.ok) return;
2721
2977
 
2722
- const cacheOptions = getCacheOptionsForRoute(route.id, registry);
2723
- if (!cacheOptions?.revalidate) return;
2978
+ // Phase 18.ζ resolve meta with the same 3-tier priority as the MISS
2979
+ // path so background revalidation honors per-request `_cache` from the
2980
+ // freshly re-executed loader.
2981
+ const resolved = resolveCacheMetaForSave(
2982
+ route.id,
2983
+ registry,
2984
+ loadResult.value.cacheMeta
2985
+ );
2986
+ if (!resolved) return;
2724
2987
 
2725
2988
  const html = await ssrResult.value.text();
2726
2989
  const entry = createCacheEntry(
2727
2990
  html,
2728
2991
  loaderData,
2729
- cacheOptions.revalidate,
2730
- cacheOptions.tags ?? [],
2992
+ {
2993
+ maxAge: resolved.maxAge,
2994
+ staleWhileRevalidate: resolved.staleWhileRevalidate,
2995
+ tags: resolved.tags,
2996
+ },
2731
2997
  ssrResult.value.status,
2732
2998
  Object.fromEntries(ssrResult.value.headers.entries())
2733
2999
  );
2734
3000
  cache.set(cacheKey, entry);
2735
3001
  }
2736
3002
 
3003
+ /**
3004
+ * Phase 18.ζ — 최종 캐시 엔트리 stamp 용 메타데이터 해석.
3005
+ *
3006
+ * 우선순위 (높음 → 낮음):
3007
+ * 1. per-request: `loadResult.value.cacheMeta` (`_cache` 또는 `ctx.cache`)
3008
+ * 2. route-level: `filling.getCacheOptions()` (`.loader(fn, {revalidate, tags})`)
3009
+ * 3. global: `ManduConfig.cache.defaultMaxAge` / `defaultSwr`
3010
+ *
3011
+ * 반환값은 다음 의미를 가진다:
3012
+ * - `null` → 저장하지 않음 (maxAge 가 0 이하이거나 캐싱 의사 없음).
3013
+ * - `{ maxAge, staleWhileRevalidate, tags }` → 저장.
3014
+ *
3015
+ * 태그는 per-request + route-level 의 합집합이다.
3016
+ */
3017
+ function resolveCacheMetaForSave(
3018
+ routeId: string,
3019
+ registry: ServerRegistry,
3020
+ perRequest: RuntimeCacheMeta | undefined
3021
+ ): { maxAge: number; staleWhileRevalidate: number; tags: string[] } | null {
3022
+ const routeLevel = registry.cacheOptions?.get(routeId) ?? null;
3023
+ // `getGlobalCacheDefaults` is imported from ./cache; defaults may be null.
3024
+ const defaults = (function () {
3025
+ try {
3026
+ // Dynamic require-free import: re-expose via the module we already
3027
+ // depend on at the top of this file (`./cache`).
3028
+ return getGlobalCacheDefaults();
3029
+ } catch {
3030
+ return null;
3031
+ }
3032
+ })();
3033
+
3034
+ const maxAge =
3035
+ perRequest?.maxAge ??
3036
+ routeLevel?.revalidate ??
3037
+ defaults?.defaultMaxAge ??
3038
+ 0;
3039
+ const swr =
3040
+ perRequest?.staleWhileRevalidate ??
3041
+ routeLevel?.staleWhileRevalidate ??
3042
+ defaults?.defaultSwr ??
3043
+ 0;
3044
+
3045
+ if (!Number.isFinite(maxAge) || maxAge <= 0) return null;
3046
+
3047
+ const tagSet = new Set<string>();
3048
+ routeLevel?.tags?.forEach((t) => tagSet.add(t));
3049
+ perRequest?.tags?.forEach((t) => tagSet.add(t));
3050
+
3051
+ return { maxAge, staleWhileRevalidate: Math.max(0, swr), tags: [...tagSet] };
3052
+ }
3053
+
2737
3054
  /**
2738
3055
  * 라우트의 캐시 옵션 가져오기 (pageHandler의 filling에서 추출)
2739
3056
  */
2740
3057
  function getCacheOptionsForRoute(
2741
3058
  routeId: string,
2742
3059
  registry: ServerRegistry
2743
- ): { revalidate?: number; tags?: string[] } | null {
3060
+ ): { revalidate?: number; staleWhileRevalidate?: number; tags?: string[] } | null {
2744
3061
  const pageHandler = registry.pageHandlers.get(routeId);
2745
3062
  if (!pageHandler) return null;
2746
3063
 
@@ -3255,6 +3572,18 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3255
3572
  console.warn(" cors: { origin: ['https://yourdomain.com'] }");
3256
3573
  }
3257
3574
 
3575
+ // Phase 18.θ — build a tracer once at boot. Honours
3576
+ // `observability.tracing` in options AND `MANDU_OTEL_ENDPOINT` env var.
3577
+ // When both are absent, `createTracerFromConfig({})` returns a disabled
3578
+ // tracer (no allocations, no per-request overhead).
3579
+ const tracerInstance: Tracer = createTracerFromConfig(
3580
+ observabilityOption?.tracing
3581
+ );
3582
+ // Install as process-global so `@mandujs/core/observability`
3583
+ // `getTracer()` returns the same instance user code sees through
3584
+ // `ctx.startSpan(...)`.
3585
+ setTracer(tracerInstance);
3586
+
3258
3587
  // Registry settings 저장 (초기값)
3259
3588
  registry.settings = {
3260
3589
  isDev,
@@ -3273,18 +3602,52 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3273
3602
  devtools,
3274
3603
  heapEndpoint: observabilityOption?.heapEndpoint,
3275
3604
  metricsEndpoint: observabilityOption?.metricsEndpoint,
3605
+ tracer: tracerInstance.enabled ? tracerInstance : undefined,
3276
3606
  prerender: prerenderSettings,
3277
3607
  middlewareChain,
3278
3608
  };
3279
3609
 
3280
3610
  registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
3281
3611
 
3282
- // ISR/SWR 캐시 초기화
3612
+ // ─── Phase 18.ζ — ISR/SWR 캐시 초기화 ──────────────────────────────────
3613
+ // `cacheOption` 는 `true` | `false` | `CacheStore` | `CacheConfig` 를 받는다:
3614
+ // - `true` → MemoryCacheStore(1000) 를 기본값으로 생성.
3615
+ // - `CacheStore` → 그대로 주입 (커스텀 어댑터, 예: redis).
3616
+ // - `CacheConfig` → { defaultMaxAge, defaultSwr, maxEntries, store }
3617
+ // 에서 store="memory"(default) 로 MemoryCacheStore 생성
3618
+ // 후 defaults 를 global 에 기록하여 per-request `_cache`
3619
+ // 가 누락된 경우에도 자동 캐싱할 수 있게 한다.
3620
+ // - `false`/미지정 → 캐시 disabled.
3283
3621
  if (cacheOption) {
3284
- const store = cacheOption === true ? new MemoryCacheStore() : cacheOption;
3285
- registry.settings.cacheStore = store;
3286
- setGlobalCache(store); // revalidatePath/revalidateTag API에서 사용
3622
+ const store = createCacheStoreFromConfig(
3623
+ cacheOption as boolean | CacheStore | CacheConfig
3624
+ );
3625
+ if (store) {
3626
+ registry.settings.cacheStore = store;
3627
+ setGlobalCache(store); // revalidatePath/revalidateTag API에서 사용
3628
+
3629
+ // CacheConfig 객체일 때만 defaults 를 전역에 등록. boolean/CacheStore
3630
+ // 형태로 들어오면 기존 동작 (per-route 설정 없으면 저장 안 함) 유지.
3631
+ if (
3632
+ typeof cacheOption === "object" &&
3633
+ cacheOption !== null &&
3634
+ !(typeof (cacheOption as CacheStore).get === "function")
3635
+ ) {
3636
+ const cfg = cacheOption as CacheConfig;
3637
+ setGlobalCacheDefaults({
3638
+ defaultMaxAge: cfg.defaultMaxAge,
3639
+ defaultSwr: cfg.defaultSwr,
3640
+ });
3641
+ } else {
3642
+ setGlobalCacheDefaults(null);
3643
+ }
3644
+ }
3645
+ } else {
3646
+ // 캐시 disabled — 이전 테스트 run 의 defaults 가 새 서버 instance 에
3647
+ // 새어 들어오지 않도록 초기화.
3648
+ setGlobalCacheDefaults(null);
3287
3649
  }
3650
+ // ─── End Phase 18.ζ ────────────────────────────────────────────────────
3288
3651
 
3289
3652
  // Kitchen dev dashboard (dev mode only)
3290
3653
  if (isDev) {