@mandujs/core 0.29.1 → 0.31.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,
@@ -69,6 +84,11 @@ import {
69
84
  type ComposedHandler,
70
85
  } from "../middleware/compose";
71
86
  import type { Middleware } from "../middleware/define";
87
+ // Phase 18.λ — scheduler wiring (statically imported so `startServer` stays
88
+ // synchronous; the cost of unused code is trivial — `defineCron` is a thin
89
+ // wrapper around `Bun.cron`).
90
+ import { defineCron as schedulerDefineCron } from "../scheduler";
91
+ import { setActiveSchedulerRegistration } from "../middleware/scheduler-cron";
72
92
  import { createFetchHandler } from "./handler";
73
93
  import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
74
94
  import { handleImageRequest } from "./image-handler";
@@ -76,6 +96,14 @@ import { extractShellHtml, createPPRResponse } from "./ppr";
76
96
  import { isRedirectResponse } from "./redirect";
77
97
  import { isNotFoundResponse } from "./not-found";
78
98
  import { newId } from "../id";
99
+ // Phase 18.κ — typed RPC dispatch (tRPC-like). See
100
+ // `packages/core/src/contract/rpc.ts` + `docs/architect/typed-rpc.md`.
101
+ import {
102
+ matchRpcPath,
103
+ dispatchRpc,
104
+ registerRpc,
105
+ clearRpcRegistry,
106
+ } from "../contract/rpc";
79
107
  import { handleMetadataRoute as dispatchMetadataRoute } from "../routes/metadata-routes";
80
108
  import {
81
109
  DEFAULT_PRERENDER_DIR,
@@ -367,12 +395,17 @@ export interface ServerOptions {
367
395
  */
368
396
  guardConfig?: import("../guard/types").GuardConfig | null;
369
397
  /**
370
- * SSR 캐시 설정 (ISR/SWR 용)
371
- * - true: 기본 메모리 캐시 (LRU 1000 엔트리)
372
- * - CacheStore: 커스텀 캐시 구현체
373
- * - false/undefined: 캐시 비활성화
398
+ * SSR 캐시 설정 (ISR/SWR 용).
399
+ *
400
+ * Phase 18.ζ `CacheConfig` 객체를 추가로 지원한다.
401
+ * - `true` : 기본 메모리 캐시 (LRU 1000 엔트리)
402
+ * - `CacheStore` : 커스텀 캐시 구현체 (e.g. redis 어댑터)
403
+ * - `CacheConfig` : `{ defaultMaxAge, defaultSwr, maxEntries, store }`
404
+ * 전역 기본값을 설정 — loader 가 `_cache` 를 안 내도
405
+ * 자동으로 캐싱됨 (Next.js `export const revalidate` 등가).
406
+ * - `false`/undefined : 캐시 비활성화
374
407
  */
375
- cache?: boolean | CacheStore;
408
+ cache?: boolean | CacheStore | CacheConfig;
376
409
  /**
377
410
  * Internal management token for local CLI/runtime control endpoints.
378
411
  * When set, token-protected endpoints such as `/_mandu/cache` become available.
@@ -429,6 +462,22 @@ export interface ServerOptions {
429
462
  observability?: {
430
463
  heapEndpoint?: boolean;
431
464
  metricsEndpoint?: boolean;
465
+ /**
466
+ * Phase 18.θ — OpenTelemetry-compatible request tracing. See
467
+ * `@mandujs/core/observability` {@link import("../observability/tracing").TracerConfig}
468
+ * for field semantics. `undefined` leaves tracing disabled;
469
+ * `{ enabled: true }` opens a root span for every request and
470
+ * propagates the trace context across `await`s via
471
+ * AsyncLocalStorage. The `MANDU_OTEL_ENDPOINT` env var forces
472
+ * tracing on with the OTLP exporter when the config is omitted.
473
+ */
474
+ tracing?: {
475
+ enabled?: boolean;
476
+ exporter?: "console" | "otlp";
477
+ endpoint?: string;
478
+ headers?: Record<string, string>;
479
+ serviceName?: string;
480
+ };
432
481
  };
433
482
  /**
434
483
  * Phase 18 — prerendered HTML pass-through (SSG).
@@ -468,6 +517,37 @@ export interface ServerOptions {
468
517
  * `secureMiddleware`, `rateLimitMiddleware`).
469
518
  */
470
519
  middleware?: Middleware[];
520
+ /**
521
+ * Phase 18.κ — tRPC-like typed RPC endpoints.
522
+ *
523
+ * Keys map to `/api/rpc/<name>/<method>` routes. Each value is a
524
+ * `defineRpc()` result (see `@mandujs/core/contract/rpc`). Populating
525
+ * this field at `startServer()` time registers every endpoint with
526
+ * the global RPC registry; the dispatcher runs BEFORE β's route
527
+ * matcher so RPC routes never collide with file-system API routes.
528
+ *
529
+ * Typically threaded from `ManduConfig.rpc.endpoints`.
530
+ */
531
+ rpc?: {
532
+ endpoints?: Record<string, import("../contract/rpc").RpcDefinition<import("../contract/rpc").RpcProcedureRecord>>;
533
+ };
534
+ /**
535
+ * Phase 18.λ — declarative cron scheduler.
536
+ *
537
+ * When `jobs` is non-empty and `disabled !== true`, `startServer()`
538
+ * instantiates a `CronRegistration` via `defineCron(jobs)`, calls
539
+ * `.start()` after the HTTP listener is bound, and wires the handle
540
+ * into `stop()` so the returned `ManduServer.stop()` also drains any
541
+ * in-flight cron tick before returning. Jobs whose `runOn` omits
542
+ * `"bun"` are registered but never fire on the local Bun host — they
543
+ * still appear in `status()` so dashboards can render their existence.
544
+ *
545
+ * Typically threaded from `ManduConfig.scheduler`.
546
+ */
547
+ scheduler?: {
548
+ jobs?: import("../scheduler").CronDef[];
549
+ disabled?: boolean;
550
+ };
471
551
  }
472
552
 
473
553
  export interface ManduServer {
@@ -625,6 +705,12 @@ export interface ServerRegistrySettings {
625
705
  * as `heapEndpoint`.
626
706
  */
627
707
  metricsEndpoint?: boolean;
708
+ /**
709
+ * Phase 18.θ — resolved request-tracing state. `undefined` means
710
+ * tracing is disabled (the hot path is branch-free). When set, every
711
+ * request opens a root span via `tracer.startSpanFromRequest()`.
712
+ */
713
+ tracer?: import("../observability/tracing").Tracer;
628
714
  /**
629
715
  * Phase 18 — resolved prerender pass-through state. `undefined`
630
716
  * means the feature is disabled for this server instance.
@@ -691,7 +777,7 @@ export class ServerRegistry {
691
777
  /** Kitchen dev dashboard handler (dev mode only) */
692
778
  kitchen: KitchenHandler | null = null;
693
779
  /** 라우트별 캐시 옵션 (filling.loader()의 cacheOptions에서 등록) */
694
- readonly cacheOptions: Map<string, { revalidate?: number; tags?: string[] }> = new Map();
780
+ readonly cacheOptions: Map<string, { revalidate?: number; staleWhileRevalidate?: number; tags?: string[] }> = new Map();
695
781
  /** 라우트별 렌더 모드 */
696
782
  readonly renderModes: Map<string, RenderMode> = new Map();
697
783
  /** Layout slot 파일 경로 캐시 (모듈 경로 → slot 경로 | null) */
@@ -1429,6 +1515,73 @@ async function handleRequest(req: Request, router: Router, registry: ServerRegis
1429
1515
  const requestStart = Date.now();
1430
1516
  // Phase 1-4: Correlation ID — 한 요청에서 발생하는 모든 이벤트를 추적
1431
1517
  const correlationId = req.headers.get("x-mandu-request-id") ?? newId();
1518
+
1519
+ // ─── Phase 18.θ — TRACING wrap (absolute TOP of request handler) ─────────
1520
+ // Opens a root server span BEFORE γ's prerendered check, BEFORE ζ's
1521
+ // cache check, BEFORE any other Phase 18 logic. The span is bound to
1522
+ // AsyncLocalStorage so every downstream `await` (middleware chain,
1523
+ // filling loader, SSR render, sandbox exec) inherits it transparently
1524
+ // via `getActiveSpan()` / `ctx.span`.
1525
+ //
1526
+ // Parent context: honours an incoming W3C `traceparent` header so
1527
+ // upstream gateways / load balancers can correlate traces across
1528
+ // service boundaries. Missing / malformed header → a new root trace-id.
1529
+ //
1530
+ // Zero overhead when tracing is disabled: `settings.tracer` is
1531
+ // `undefined`, the `if` falls through, and we call the uninstrumented
1532
+ // path exactly as before.
1533
+ const tracer = registry.settings.tracer;
1534
+ if (tracer && tracer.enabled) {
1535
+ const url = new URL(req.url);
1536
+ const rootSpan = tracer.startSpanFromRequest("http.request", req, {
1537
+ kind: "server",
1538
+ attributes: {
1539
+ "http.method": req.method,
1540
+ "http.url": req.url,
1541
+ "http.target": url.pathname,
1542
+ "http.scheme": url.protocol.replace(":", ""),
1543
+ "http.host": url.host,
1544
+ "mandu.correlation_id": correlationId,
1545
+ },
1546
+ });
1547
+ try {
1548
+ const response = await runWithSpan(rootSpan, () =>
1549
+ handleRequestWithTracing(req, router, registry, requestStart, correlationId)
1550
+ );
1551
+ rootSpan.setAttribute("http.status_code", response.status);
1552
+ if (response.status >= 500) {
1553
+ rootSpan.setStatus("error", `HTTP ${response.status}`);
1554
+ } else if (rootSpan.status === "unset") {
1555
+ rootSpan.setStatus("ok");
1556
+ }
1557
+ return response;
1558
+ } catch (err) {
1559
+ const msg = err instanceof Error ? err.message : String(err);
1560
+ rootSpan.setStatus("error", msg);
1561
+ throw err;
1562
+ } finally {
1563
+ rootSpan.end();
1564
+ }
1565
+ }
1566
+ // ─── End Phase 18.θ ──────────────────────────────────────────────────────
1567
+
1568
+ return await handleRequestWithTracing(req, router, registry, requestStart, correlationId);
1569
+ }
1570
+
1571
+ /**
1572
+ * Phase 18.θ — inner request handler. Extracted from {@link handleRequest}
1573
+ * so the tracing wrap at the top can run the body inside a
1574
+ * `runWithSpan()` scope without a giant indent level. Preserves the
1575
+ * exact pre-tracing semantics (correlation-id logging, Cache-Control
1576
+ * stamping, eventBus emission).
1577
+ */
1578
+ async function handleRequestWithTracing(
1579
+ req: Request,
1580
+ router: Router,
1581
+ registry: ServerRegistry,
1582
+ requestStart: number,
1583
+ correlationId: string
1584
+ ): Promise<Response> {
1432
1585
  const result = await handleRequestInternal(req, router, registry);
1433
1586
 
1434
1587
  if (!result.ok) {
@@ -1629,11 +1782,24 @@ export function redactErrorForBoundary(error: Error, isDev: boolean): { error: E
1629
1782
  return { error: redacted, digest };
1630
1783
  }
1631
1784
 
1785
+ /**
1786
+ * Phase 18.ζ — per-request 로더 캐시 메타데이터. loader 반환값의 `_cache`
1787
+ * 프로퍼티 또는 `ctx.cache.*` 플루언트 호출에서 수집되어 렌더 후 캐시
1788
+ * 저장 단계에서 route-level defaults 와 merge 된다.
1789
+ */
1790
+ export interface RuntimeCacheMeta {
1791
+ tags: string[];
1792
+ maxAge?: number;
1793
+ staleWhileRevalidate?: number;
1794
+ }
1795
+
1632
1796
  interface PageLoadResult {
1633
1797
  loaderData: unknown;
1634
1798
  cookies?: CookieManager;
1635
1799
  /** Layout별 loader 데이터 (모듈 경로 → 데이터) */
1636
1800
  layoutData?: Map<string, unknown>;
1801
+ /** Phase 18.ζ — per-request 캐시 메타데이터 (_cache or ctx.cache.*) */
1802
+ cacheMeta?: RuntimeCacheMeta;
1637
1803
  /**
1638
1804
  * If the page's loader returned or threw a redirect Response, it surfaces
1639
1805
  * here. Callers short-circuit SSR and emit this Response to the browser
@@ -1656,6 +1822,71 @@ interface PageLoadResult {
1656
1822
  notFound?: Response;
1657
1823
  }
1658
1824
 
1825
+ /**
1826
+ * Phase 18.ζ — loader 반환값에서 `_cache` 블록을 분리한다.
1827
+ *
1828
+ * 반환값이 `{ _cache: {...}, ...rest }` 모양이면 `_cache` 를 추출하고
1829
+ * 나머지를 실제 loader 데이터로 돌려준다. `_cache` 형태가 잘못되면
1830
+ * 조용히 무시하여 런타임이 깨지지 않도록 한다.
1831
+ */
1832
+ function extractCacheMetaFromReturn(
1833
+ returned: unknown
1834
+ ): { data: unknown; meta: RuntimeCacheMeta | null } {
1835
+ if (
1836
+ returned &&
1837
+ typeof returned === "object" &&
1838
+ !Array.isArray(returned) &&
1839
+ "_cache" in (returned as Record<string, unknown>)
1840
+ ) {
1841
+ const obj = returned as Record<string, unknown>;
1842
+ const raw = obj._cache as Record<string, unknown> | null | undefined;
1843
+ const data = obj.data ?? (() => {
1844
+ // { _cache, ...rest } → strip _cache from the object
1845
+ const { _cache: _omit, ...rest } = obj as Record<string, unknown>;
1846
+ return rest;
1847
+ })();
1848
+ if (!raw || typeof raw !== "object") {
1849
+ return { data, meta: null };
1850
+ }
1851
+ const tagsRaw = Array.isArray(raw.tags) ? raw.tags : [];
1852
+ const tags = tagsRaw.filter((t): t is string => typeof t === "string" && t.length > 0);
1853
+ const maxAgeRaw = typeof raw.maxAge === "number"
1854
+ ? raw.maxAge
1855
+ : typeof raw.revalidate === "number"
1856
+ ? raw.revalidate
1857
+ : undefined;
1858
+ const swrRaw = typeof raw.staleWhileRevalidate === "number"
1859
+ ? raw.staleWhileRevalidate
1860
+ : typeof raw.swr === "number"
1861
+ ? raw.swr
1862
+ : undefined;
1863
+ return {
1864
+ data,
1865
+ meta: { tags, maxAge: maxAgeRaw, staleWhileRevalidate: swrRaw },
1866
+ };
1867
+ }
1868
+ return { data: returned, meta: null };
1869
+ }
1870
+
1871
+ /**
1872
+ * Phase 18.ζ — ctx.cache 스냅샷과 `_cache` 리턴 메타데이터를 병합한다.
1873
+ * 둘 다 없으면 null. 태그는 합집합, 숫자는 return 값이 우선.
1874
+ */
1875
+ function mergeRuntimeCacheMeta(
1876
+ fromReturn: RuntimeCacheMeta | null,
1877
+ fromCtx: { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } | null
1878
+ ): RuntimeCacheMeta | null {
1879
+ if (!fromReturn && !fromCtx) return null;
1880
+ const tags = new Set<string>();
1881
+ fromReturn?.tags?.forEach((t) => tags.add(t));
1882
+ fromCtx?.tags?.forEach((t) => tags.add(t));
1883
+ return {
1884
+ tags: [...tags],
1885
+ maxAge: fromReturn?.maxAge ?? fromCtx?.maxAge,
1886
+ staleWhileRevalidate: fromReturn?.staleWhileRevalidate ?? fromCtx?.staleWhileRevalidate,
1887
+ };
1888
+ }
1889
+
1659
1890
  /**
1660
1891
  * 페이지 컴포넌트 및 loader 데이터 로딩
1661
1892
  */
@@ -1710,10 +1941,16 @@ async function loadPageData(
1710
1941
  const redirectResponse = mergeCookiesIntoResponse(returned, ctx.cookies);
1711
1942
  return ok({ loaderData: undefined, redirect: redirectResponse });
1712
1943
  }
1713
- loaderData = returned;
1944
+ // Phase 18.ζ — per-request cache meta collection
1945
+ const { data: strippedData, meta: returnMeta } = extractCacheMetaFromReturn(returned);
1946
+ const mergedMeta = mergeRuntimeCacheMeta(returnMeta, ctx.getCacheMetaSnapshot());
1947
+ loaderData = strippedData;
1714
1948
  if (ctx.cookies.hasPendingCookies()) {
1715
1949
  cookies = ctx.cookies;
1716
1950
  }
1951
+ if (mergedMeta) {
1952
+ return ok({ loaderData, cookies, cacheMeta: mergedMeta });
1953
+ }
1717
1954
  }
1718
1955
  } catch (error) {
1719
1956
  const pageError = createPageLoadErrorResponse(
@@ -1816,10 +2053,16 @@ async function loadPageData(
1816
2053
  const redirectResponse = mergeCookiesIntoResponse(returned, ctx.cookies);
1817
2054
  return ok({ loaderData: undefined, redirect: redirectResponse });
1818
2055
  }
1819
- loaderData = returned;
2056
+ // Phase 18.ζ — per-request cache meta collection (legacy pageLoader path)
2057
+ const { data: strippedData, meta: returnMeta } = extractCacheMetaFromReturn(returned);
2058
+ const mergedMeta = mergeRuntimeCacheMeta(returnMeta, ctx.getCacheMetaSnapshot());
2059
+ loaderData = strippedData;
1820
2060
  if (ctx.cookies.hasPendingCookies()) {
1821
2061
  cookies = ctx.cookies;
1822
2062
  }
2063
+ if (mergedMeta) {
2064
+ return ok({ loaderData, cookies, cacheMeta: mergedMeta });
2065
+ }
1823
2066
  }
1824
2067
 
1825
2068
  return ok({ loaderData, cookies });
@@ -2536,7 +2779,19 @@ async function handlePageRoute(
2536
2779
  // Shell MISS: fall through to full render, then cache the shell below
2537
2780
  }
2538
2781
 
2539
- // ISR/SWR 캐시 확인 (SSR 렌더링 요청에만 적용)
2782
+ // ─── Phase 18.ζ ISR + cache-tags dispatch ────────────────────────────
2783
+ // Runs AFTER γ's prerendered pass-through (handled earlier in
2784
+ // `dispatchRequest`) and BEFORE full route dispatch.
2785
+ // HIT : serve fresh cache entry directly, no SSR work.
2786
+ // STALE : serve stale HTML immediately + background revalidation
2787
+ // under a per-key mutex (`pendingRevalidations`).
2788
+ // MISS : fall through to render. Save happens below (step 4b)
2789
+ // after `_cache` / `ctx.cache` metadata has been collected
2790
+ // from the loader return.
2791
+ //
2792
+ // Served responses carry `Cache-Control: public, max-age=…,
2793
+ // stale-while-revalidate=…` for CDN alignment plus the debug header
2794
+ // `X-Mandu-Cache: HIT|STALE`.
2540
2795
  if (cache && !isDataRequest && renderMode !== "dynamic" && renderMode !== "ppr") {
2541
2796
  const cacheKey = buildRouteCacheKey(route.id, url);
2542
2797
  const lookup = lookupCache(cache, cacheKey);
@@ -2563,6 +2818,7 @@ async function handlePageRoute(
2563
2818
  return ok(createCachedResponse(lookup.entry, "STALE"));
2564
2819
  }
2565
2820
  }
2821
+ // ─── End Phase 18.ζ ────────────────────────────────────────────────────
2566
2822
 
2567
2823
  // 1. 페이지 + 레이아웃 데이터 병렬 로딩
2568
2824
  const [loadResult, layoutLoad] = await Promise.all([
@@ -2671,22 +2927,66 @@ async function handlePageRoute(
2671
2927
  }).catch(() => {});
2672
2928
  }
2673
2929
 
2674
- // 4b. ISR/SWR 캐시 저장 (revalidate 설정이 있는 경우 — non-blocking)
2930
+ // ─── Phase 18.ζ — ISR/SWR cache save (step 4b) ────────────────────────
2931
+ // Resolves the effective cache metadata from three tiers (per-request
2932
+ // `_cache` / `ctx.cache` → route-level `filling.loader(fn, {...})` →
2933
+ // global `ManduConfig.cache.defaultMaxAge/defaultSwr`). When a valid
2934
+ // `maxAge > 0` is resolved, we:
2935
+ // 1. Persist the rendered HTML under the same key used by the HIT
2936
+ // path above (`buildRouteCacheKey`).
2937
+ // 2. Stamp the outgoing Response with `Cache-Control: public,
2938
+ // max-age=…, stale-while-revalidate=…` + `X-Mandu-Cache: MISS` so
2939
+ // CDNs and browsers can coordinate the same TTL we stored.
2940
+ // The persisted entry has `tags` union'd across tiers — invalidation
2941
+ // via `revalidateTag()` therefore hits both developer-declared tags
2942
+ // and per-request tags for this URL.
2675
2943
  if (cache && ssrResult.ok && renderMode !== "dynamic" && renderMode !== "ppr") {
2676
- const cacheOptions = getCacheOptionsForRoute(route.id, registry);
2677
- if (cacheOptions?.revalidate && cacheOptions.revalidate > 0) {
2944
+ const perRequest = loadResult.value.cacheMeta;
2945
+ const resolved = resolveCacheMetaForSave(route.id, registry, perRequest);
2946
+ if (resolved) {
2678
2947
  const cloned = ssrResult.value.clone();
2679
2948
  const status = ssrResult.value.status;
2680
2949
  const headers = Object.fromEntries(ssrResult.value.headers.entries());
2681
2950
  const cacheKey = buildRouteCacheKey(route.id, url);
2682
2951
  // streaming 응답도 블로킹하지 않도록 백그라운드에서 캐시 저장
2683
2952
  cloned.text().then((html) => {
2684
- cache.set(cacheKey, createCacheEntry(
2685
- html, loaderData, cacheOptions.revalidate!, cacheOptions.tags ?? [], status, headers
2686
- ));
2953
+ cache.set(
2954
+ cacheKey,
2955
+ createCacheEntry(
2956
+ html,
2957
+ loaderData,
2958
+ {
2959
+ maxAge: resolved.maxAge,
2960
+ staleWhileRevalidate: resolved.staleWhileRevalidate,
2961
+ tags: resolved.tags,
2962
+ },
2963
+ status,
2964
+ headers
2965
+ )
2966
+ );
2687
2967
  }).catch(() => {});
2968
+
2969
+ // Stamp Cache-Control + X-Mandu-Cache on the MISS response so
2970
+ // downstream CDNs honor the same TTL we just persisted.
2971
+ const freshEntryPreview = createCacheEntry(
2972
+ "",
2973
+ null,
2974
+ { maxAge: resolved.maxAge, staleWhileRevalidate: resolved.staleWhileRevalidate, tags: resolved.tags },
2975
+ status,
2976
+ {}
2977
+ );
2978
+ const cc = computeCacheControl(freshEntryPreview);
2979
+ const stamped = new Response(ssrResult.value.body, {
2980
+ status: ssrResult.value.status,
2981
+ statusText: ssrResult.value.statusText,
2982
+ headers: ssrResult.value.headers,
2983
+ });
2984
+ stamped.headers.set("Cache-Control", cc);
2985
+ stamped.headers.set("X-Mandu-Cache", "MISS");
2986
+ return ok(stamped);
2688
2987
  }
2689
2988
  }
2989
+ // ─── End Phase 18.ζ ────────────────────────────────────────────────────
2690
2990
 
2691
2991
  return ssrResult;
2692
2992
  }
@@ -2719,28 +3019,89 @@ async function regenerateCache(
2719
3019
  const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, undefined, layoutData);
2720
3020
  if (!ssrResult.ok) return;
2721
3021
 
2722
- const cacheOptions = getCacheOptionsForRoute(route.id, registry);
2723
- if (!cacheOptions?.revalidate) return;
3022
+ // Phase 18.ζ resolve meta with the same 3-tier priority as the MISS
3023
+ // path so background revalidation honors per-request `_cache` from the
3024
+ // freshly re-executed loader.
3025
+ const resolved = resolveCacheMetaForSave(
3026
+ route.id,
3027
+ registry,
3028
+ loadResult.value.cacheMeta
3029
+ );
3030
+ if (!resolved) return;
2724
3031
 
2725
3032
  const html = await ssrResult.value.text();
2726
3033
  const entry = createCacheEntry(
2727
3034
  html,
2728
3035
  loaderData,
2729
- cacheOptions.revalidate,
2730
- cacheOptions.tags ?? [],
3036
+ {
3037
+ maxAge: resolved.maxAge,
3038
+ staleWhileRevalidate: resolved.staleWhileRevalidate,
3039
+ tags: resolved.tags,
3040
+ },
2731
3041
  ssrResult.value.status,
2732
3042
  Object.fromEntries(ssrResult.value.headers.entries())
2733
3043
  );
2734
3044
  cache.set(cacheKey, entry);
2735
3045
  }
2736
3046
 
3047
+ /**
3048
+ * Phase 18.ζ — 최종 캐시 엔트리 stamp 용 메타데이터 해석.
3049
+ *
3050
+ * 우선순위 (높음 → 낮음):
3051
+ * 1. per-request: `loadResult.value.cacheMeta` (`_cache` 또는 `ctx.cache`)
3052
+ * 2. route-level: `filling.getCacheOptions()` (`.loader(fn, {revalidate, tags})`)
3053
+ * 3. global: `ManduConfig.cache.defaultMaxAge` / `defaultSwr`
3054
+ *
3055
+ * 반환값은 다음 의미를 가진다:
3056
+ * - `null` → 저장하지 않음 (maxAge 가 0 이하이거나 캐싱 의사 없음).
3057
+ * - `{ maxAge, staleWhileRevalidate, tags }` → 저장.
3058
+ *
3059
+ * 태그는 per-request + route-level 의 합집합이다.
3060
+ */
3061
+ function resolveCacheMetaForSave(
3062
+ routeId: string,
3063
+ registry: ServerRegistry,
3064
+ perRequest: RuntimeCacheMeta | undefined
3065
+ ): { maxAge: number; staleWhileRevalidate: number; tags: string[] } | null {
3066
+ const routeLevel = registry.cacheOptions?.get(routeId) ?? null;
3067
+ // `getGlobalCacheDefaults` is imported from ./cache; defaults may be null.
3068
+ const defaults = (function () {
3069
+ try {
3070
+ // Dynamic require-free import: re-expose via the module we already
3071
+ // depend on at the top of this file (`./cache`).
3072
+ return getGlobalCacheDefaults();
3073
+ } catch {
3074
+ return null;
3075
+ }
3076
+ })();
3077
+
3078
+ const maxAge =
3079
+ perRequest?.maxAge ??
3080
+ routeLevel?.revalidate ??
3081
+ defaults?.defaultMaxAge ??
3082
+ 0;
3083
+ const swr =
3084
+ perRequest?.staleWhileRevalidate ??
3085
+ routeLevel?.staleWhileRevalidate ??
3086
+ defaults?.defaultSwr ??
3087
+ 0;
3088
+
3089
+ if (!Number.isFinite(maxAge) || maxAge <= 0) return null;
3090
+
3091
+ const tagSet = new Set<string>();
3092
+ routeLevel?.tags?.forEach((t) => tagSet.add(t));
3093
+ perRequest?.tags?.forEach((t) => tagSet.add(t));
3094
+
3095
+ return { maxAge, staleWhileRevalidate: Math.max(0, swr), tags: [...tagSet] };
3096
+ }
3097
+
2737
3098
  /**
2738
3099
  * 라우트의 캐시 옵션 가져오기 (pageHandler의 filling에서 추출)
2739
3100
  */
2740
3101
  function getCacheOptionsForRoute(
2741
3102
  routeId: string,
2742
3103
  registry: ServerRegistry
2743
- ): { revalidate?: number; tags?: string[] } | null {
3104
+ ): { revalidate?: number; staleWhileRevalidate?: number; tags?: string[] } | null {
2744
3105
  const pageHandler = registry.pageHandlers.get(routeId);
2745
3106
  if (!pageHandler) return null;
2746
3107
 
@@ -3008,6 +3369,47 @@ async function handleRequestInternal(
3008
3369
  }
3009
3370
  // ─── End Phase 18.ε ──────────────────────────────────────────────────────
3010
3371
 
3372
+ // ─── Phase 18.κ — typed RPC dispatch ──────────────────────────────────────
3373
+ // Runs AFTER γ's prerendered pass-through (handled earlier at step 0.5),
3374
+ // AFTER ζ's ISR/SWR cache check (per-route, inside handlePageRoute), and
3375
+ // BEFORE β's file-system route dispatch below. The canonical URL shape is
3376
+ //
3377
+ // POST /api/rpc/<endpoint>/<method>
3378
+ //
3379
+ // with JSON body `{ input: <value> }`. The dispatcher:
3380
+ // 1. matches `/api/rpc/<name>/<method>` via `matchRpcPath()` (returns
3381
+ // `null` for any other path — then we fall through to β),
3382
+ // 2. looks up the registered `RpcDefinition` in the module-level
3383
+ // `rpcRegistry` (populated by `registerRpc` at boot from
3384
+ // `ServerOptions.rpc.endpoints`),
3385
+ // 3. validates the request body against `procedure.input` (Zod),
3386
+ // invokes `procedure.handler`, validates the return value against
3387
+ // `procedure.output` (Zod), and ships a
3388
+ // `{ ok: true, data } | { ok: false, error }` JSON envelope.
3389
+ //
3390
+ // All failure paths return structured envelopes — never throws — so the
3391
+ // outer request handler's 5xx catch is unreachable on the happy path.
3392
+ // See `packages/core/src/contract/rpc.ts` and
3393
+ // `docs/architect/typed-rpc.md`.
3394
+ {
3395
+ const rpcMatch = matchRpcPath(pathname);
3396
+ if (rpcMatch) {
3397
+ const rpcResponse = await dispatchRpc(
3398
+ req,
3399
+ rpcMatch.endpoint,
3400
+ rpcMatch.method,
3401
+ { isDev: settings.isDev }
3402
+ );
3403
+ if (settings.cors && isCorsRequest(req)) {
3404
+ const corsOptions: CorsOptions =
3405
+ typeof settings.cors === "object" ? settings.cors : {};
3406
+ return ok(applyCorsToResponse(rpcResponse, req, corsOptions));
3407
+ }
3408
+ return ok(rpcResponse);
3409
+ }
3410
+ }
3411
+ // ─── End Phase 18.κ ───────────────────────────────────────────────────────
3412
+
3011
3413
  // 3. 라우트 매칭
3012
3414
  const match = router.match(pathname);
3013
3415
  if (!match) {
@@ -3206,6 +3608,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3206
3608
  observability: observabilityOption,
3207
3609
  prerender: prerenderOption,
3208
3610
  middleware: middlewareOption,
3611
+ rpc: rpcOption,
3612
+ scheduler: schedulerOption,
3209
3613
  } = options;
3210
3614
 
3211
3615
  // Phase 18.ε — build the request-level middleware chain once at boot.
@@ -3255,6 +3659,18 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3255
3659
  console.warn(" cors: { origin: ['https://yourdomain.com'] }");
3256
3660
  }
3257
3661
 
3662
+ // Phase 18.θ — build a tracer once at boot. Honours
3663
+ // `observability.tracing` in options AND `MANDU_OTEL_ENDPOINT` env var.
3664
+ // When both are absent, `createTracerFromConfig({})` returns a disabled
3665
+ // tracer (no allocations, no per-request overhead).
3666
+ const tracerInstance: Tracer = createTracerFromConfig(
3667
+ observabilityOption?.tracing
3668
+ );
3669
+ // Install as process-global so `@mandujs/core/observability`
3670
+ // `getTracer()` returns the same instance user code sees through
3671
+ // `ctx.startSpan(...)`.
3672
+ setTracer(tracerInstance);
3673
+
3258
3674
  // Registry settings 저장 (초기값)
3259
3675
  registry.settings = {
3260
3676
  isDev,
@@ -3273,18 +3689,68 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3273
3689
  devtools,
3274
3690
  heapEndpoint: observabilityOption?.heapEndpoint,
3275
3691
  metricsEndpoint: observabilityOption?.metricsEndpoint,
3692
+ tracer: tracerInstance.enabled ? tracerInstance : undefined,
3276
3693
  prerender: prerenderSettings,
3277
3694
  middlewareChain,
3278
3695
  };
3279
3696
 
3280
3697
  registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
3281
3698
 
3282
- // ISR/SWR 캐시 초기화
3699
+ // ─── Phase 18.κ — register RPC endpoints from options ──────────────────
3700
+ // The RPC registry is module-scoped (shared across all server
3701
+ // instances in this process). Clearing first keeps repeated
3702
+ // `startServer()` calls in tests deterministic — otherwise a stale
3703
+ // endpoint from a prior run could answer a later instance's requests.
3704
+ //
3705
+ // This runs once at boot; HMR-time re-registration goes through the
3706
+ // exported `registerRpc()` from `@mandujs/core/contract/rpc`.
3707
+ clearRpcRegistry();
3708
+ if (rpcOption?.endpoints) {
3709
+ for (const [name, definition] of Object.entries(rpcOption.endpoints)) {
3710
+ registerRpc(name, definition);
3711
+ }
3712
+ }
3713
+ // ─── End Phase 18.κ ────────────────────────────────────────────────────
3714
+
3715
+ // ─── Phase 18.ζ — ISR/SWR 캐시 초기화 ──────────────────────────────────
3716
+ // `cacheOption` 는 `true` | `false` | `CacheStore` | `CacheConfig` 를 받는다:
3717
+ // - `true` → MemoryCacheStore(1000) 를 기본값으로 생성.
3718
+ // - `CacheStore` → 그대로 주입 (커스텀 어댑터, 예: redis).
3719
+ // - `CacheConfig` → { defaultMaxAge, defaultSwr, maxEntries, store }
3720
+ // 에서 store="memory"(default) 로 MemoryCacheStore 생성
3721
+ // 후 defaults 를 global 에 기록하여 per-request `_cache`
3722
+ // 가 누락된 경우에도 자동 캐싱할 수 있게 한다.
3723
+ // - `false`/미지정 → 캐시 disabled.
3283
3724
  if (cacheOption) {
3284
- const store = cacheOption === true ? new MemoryCacheStore() : cacheOption;
3285
- registry.settings.cacheStore = store;
3286
- setGlobalCache(store); // revalidatePath/revalidateTag API에서 사용
3725
+ const store = createCacheStoreFromConfig(
3726
+ cacheOption as boolean | CacheStore | CacheConfig
3727
+ );
3728
+ if (store) {
3729
+ registry.settings.cacheStore = store;
3730
+ setGlobalCache(store); // revalidatePath/revalidateTag API에서 사용
3731
+
3732
+ // CacheConfig 객체일 때만 defaults 를 전역에 등록. boolean/CacheStore
3733
+ // 형태로 들어오면 기존 동작 (per-route 설정 없으면 저장 안 함) 유지.
3734
+ if (
3735
+ typeof cacheOption === "object" &&
3736
+ cacheOption !== null &&
3737
+ !(typeof (cacheOption as CacheStore).get === "function")
3738
+ ) {
3739
+ const cfg = cacheOption as CacheConfig;
3740
+ setGlobalCacheDefaults({
3741
+ defaultMaxAge: cfg.defaultMaxAge,
3742
+ defaultSwr: cfg.defaultSwr,
3743
+ });
3744
+ } else {
3745
+ setGlobalCacheDefaults(null);
3746
+ }
3747
+ }
3748
+ } else {
3749
+ // 캐시 disabled — 이전 테스트 run 의 defaults 가 새 서버 instance 에
3750
+ // 새어 들어오지 않도록 초기화.
3751
+ setGlobalCacheDefaults(null);
3287
3752
  }
3753
+ // ─── End Phase 18.ζ ────────────────────────────────────────────────────
3288
3754
 
3289
3755
  // Kitchen dev dashboard (dev mode only)
3290
3756
  if (isDev) {
@@ -3425,12 +3891,64 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3425
3891
  }
3426
3892
  }
3427
3893
 
3894
+ // ─── Phase 18.λ — declarative cron scheduler ────────────────────────────
3895
+ // Boot the scheduler AFTER the HTTP listener is live so a malformed cron
3896
+ // expression (caught by validateCronExpression) surfaces alongside the
3897
+ // other boot errors rather than aborting the server. `startServer` is
3898
+ // synchronous by contract, so we use the already-imported scheduler
3899
+ // module rather than `await import`.
3900
+ let schedulerRegistration: import("../scheduler").CronRegistration | null = null;
3901
+ const jobDefs = schedulerOption?.jobs ?? [];
3902
+ const schedulerDisabled = schedulerOption?.disabled === true;
3903
+ if (jobDefs.length > 0 && !schedulerDisabled) {
3904
+ try {
3905
+ schedulerRegistration = schedulerDefineCron(jobDefs);
3906
+ schedulerRegistration.start();
3907
+ setActiveSchedulerRegistration(schedulerRegistration);
3908
+ const bunJobCount = Object.keys(schedulerRegistration.status()).filter(
3909
+ (name) => {
3910
+ const def = jobDefs.find((j) => j.name === name);
3911
+ const runOn = def?.runOn && def.runOn.length > 0 ? def.runOn : ["bun", "workers"];
3912
+ return runOn.includes("bun");
3913
+ },
3914
+ ).length;
3915
+ console.log(
3916
+ `⏰ Scheduler: ${bunJobCount} cron job(s) registered on Bun runtime` +
3917
+ (jobDefs.length !== bunJobCount
3918
+ ? ` (${jobDefs.length - bunJobCount} workers-only — see wrangler.toml)`
3919
+ : ""),
3920
+ );
3921
+ } catch (err) {
3922
+ // Scheduler failures MUST NOT crash the server — a bad cron string is
3923
+ // a developer error, but the HTTP surface should keep serving. Log
3924
+ // loudly and leave the registration null.
3925
+ console.error(
3926
+ "❌ [scheduler] failed to start — HTTP server continues without cron jobs:",
3927
+ err instanceof Error ? err.message : err,
3928
+ );
3929
+ }
3930
+ }
3931
+
3428
3932
  return {
3429
3933
  server,
3430
3934
  router,
3431
3935
  registry,
3432
3936
  stop: () => {
3433
3937
  registry.kitchen?.stop();
3938
+ // Fire-and-forget the async scheduler drain so `stop()` stays
3939
+ // synchronous for backwards compatibility with existing consumers.
3940
+ // Tests that need to await drain can reach for `registration.stop()`
3941
+ // directly; `server.stop()` triggers shutdown but doesn't block on
3942
+ // in-flight cron handler completion here.
3943
+ if (schedulerRegistration) {
3944
+ const reg = schedulerRegistration;
3945
+ schedulerRegistration = null;
3946
+ void reg.stop()
3947
+ .then(() => setActiveSchedulerRegistration(null))
3948
+ .catch((err) => {
3949
+ console.error("[scheduler] shutdown error:", err);
3950
+ });
3951
+ }
3434
3952
  server.stop();
3435
3953
  },
3436
3954
  };