@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.
- package/package.json +10 -1
- package/src/bundler/analyzer.ts +843 -0
- package/src/config/mandu.ts +68 -0
- package/src/config/validate.ts +44 -0
- package/src/filling/context.ts +166 -0
- package/src/filling/filling.ts +10 -1
- package/src/index.ts +32 -0
- package/src/observability/index.ts +26 -0
- package/src/observability/tracing.ts +694 -0
- package/src/runtime/cache.ts +197 -13
- package/src/runtime/index.ts +8 -0
- package/src/runtime/server.ts +387 -24
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
-
*
|
|
372
|
-
*
|
|
373
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
2677
|
-
|
|
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(
|
|
2685
|
-
|
|
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
|
-
|
|
2723
|
-
|
|
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
|
-
|
|
2730
|
-
|
|
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 =
|
|
3285
|
-
|
|
3286
|
-
|
|
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) {
|