@mandujs/core 0.28.0 → 0.29.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.
@@ -62,6 +62,13 @@ import {
62
62
  type MiddlewareConfig,
63
63
  loadMiddlewareSync,
64
64
  } from "./middleware";
65
+ // Phase 18.ε — canonical request-level middleware composition API.
66
+ // See `packages/core/src/middleware/{define,compose,bridge}.ts`.
67
+ import {
68
+ compose as composeMiddleware,
69
+ type ComposedHandler,
70
+ } from "../middleware/compose";
71
+ import type { Middleware } from "../middleware/define";
65
72
  import { createFetchHandler } from "./handler";
66
73
  import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
67
74
  import { handleImageRequest } from "./image-handler";
@@ -70,6 +77,18 @@ import { isRedirectResponse } from "./redirect";
70
77
  import { isNotFoundResponse } from "./not-found";
71
78
  import { newId } from "../id";
72
79
  import { handleMetadataRoute as dispatchMetadataRoute } from "../routes/metadata-routes";
80
+ import {
81
+ DEFAULT_PRERENDER_DIR,
82
+ DEFAULT_PRERENDER_CACHE_CONTROL,
83
+ loadPrerenderIndex,
84
+ resolvePrerenderedFile,
85
+ type PrerenderIndex,
86
+ } from "../bundler/prerender";
87
+ import {
88
+ buildOverlayErrorHtml,
89
+ buildPayloadFromError,
90
+ shouldInjectOverlay,
91
+ } from "../dev-error-overlay";
73
92
 
74
93
  export interface RateLimitOptions {
75
94
  windowMs?: number;
@@ -411,6 +430,44 @@ export interface ServerOptions {
411
430
  heapEndpoint?: boolean;
412
431
  metricsEndpoint?: boolean;
413
432
  };
433
+ /**
434
+ * Phase 18 — prerendered HTML pass-through (SSG).
435
+ *
436
+ * When enabled (default), the server looks for a prerender index
437
+ * under `<rootDir>/<dir>/_manifest.json` (written by `mandu build`)
438
+ * and, for every request whose pathname maps to a prerendered file,
439
+ * serves that HTML directly, bypassing SSR entirely, with a long
440
+ * `Cache-Control` header.
441
+ *
442
+ * - `true` enabled with defaults (dir `.mandu/prerendered`,
443
+ * Cache-Control `public, max-age=31536000, immutable`).
444
+ * - `false` disabled. Every request goes through SSR.
445
+ * - object overrides. `dir` chooses a different output;
446
+ * `cacheControl` lets adapters tune the CDN hint.
447
+ *
448
+ * Wired from `ManduConfig.build.prerender`; see
449
+ * `@mandujs/core/bundler/prerender` for the build-side contract.
450
+ */
451
+ prerender?:
452
+ | boolean
453
+ | {
454
+ dir?: string;
455
+ cacheControl?: string;
456
+ };
457
+ /**
458
+ * Phase 18.ε — canonical request-level middleware chain.
459
+ *
460
+ * Middleware execute in declaration order (outermost first) BEFORE
461
+ * route dispatch. Each layer may short-circuit by returning a
462
+ * Response without calling `next()`, or wrap the downstream Response
463
+ * after `next()` returns. Composed via `compose()`.
464
+ *
465
+ * Typically wired from `ManduConfig.middleware`. See
466
+ * `@mandujs/core/middleware` for `defineMiddleware` / `compose`
467
+ * helpers plus bridge wrappers (`csrfMiddleware`, `sessionMiddleware`,
468
+ * `secureMiddleware`, `rateLimitMiddleware`).
469
+ */
470
+ middleware?: Middleware[];
414
471
  }
415
472
 
416
473
  export interface ManduServer {
@@ -552,6 +609,12 @@ export interface ServerRegistrySettings {
552
609
  * dev-mode `_devtools.js` `<script>` injection on / off. No-op in prod.
553
610
  */
554
611
  devtools?: boolean;
612
+ /**
613
+ * Phase 18.α — threaded from `ManduConfig.dev.errorOverlay`. `undefined`
614
+ * means "use default (enabled in dev)"; `false` suppresses both the
615
+ * in-head `<script>` and the 500-response HTML overlay. No-op in prod.
616
+ */
617
+ errorOverlay?: boolean;
555
618
  /**
556
619
  * Phase 17 — `/_mandu/heap` JSON exposure. `undefined` uses the
557
620
  * default for the current mode (dev → on, prod → MANDU_DEBUG_HEAP).
@@ -562,6 +625,31 @@ export interface ServerRegistrySettings {
562
625
  * as `heapEndpoint`.
563
626
  */
564
627
  metricsEndpoint?: boolean;
628
+ /**
629
+ * Phase 18 — resolved prerender pass-through state. `undefined`
630
+ * means the feature is disabled for this server instance.
631
+ */
632
+ prerender?: {
633
+ /** Absolute output directory containing `_manifest.json`. */
634
+ dir: string;
635
+ /** `Cache-Control` header to stamp on served prerendered HTML. */
636
+ cacheControl: string;
637
+ /**
638
+ * Loaded index. `undefined` = not yet attempted; `null` = attempted
639
+ * and missing / unreadable (fall through to SSR). Populated lazily
640
+ * on the first request so `startServer` stays synchronous.
641
+ */
642
+ index?: PrerenderIndex | null;
643
+ /** In-flight load promise so concurrent requests share one read. */
644
+ pending?: Promise<PrerenderIndex | null>;
645
+ };
646
+ /**
647
+ * Phase 18.ε — precompiled request-level middleware chain. `undefined`
648
+ * means "no middleware configured" (zero overhead — the pipeline falls
649
+ * straight through to route dispatch). Wired from
650
+ * `ServerOptions.middleware` at {@link startServer} time.
651
+ */
652
+ middlewareChain?: ComposedHandler;
565
653
  }
566
654
 
567
655
  export class ServerRegistry {
@@ -2019,7 +2107,7 @@ function extractTitleText(titleHtml: string): string | null {
2019
2107
  * SSR 렌더링 (Streaming/Non-streaming)
2020
2108
  */
2021
2109
  async function renderPageSSR(
2022
- route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig; errorModule?: string },
2110
+ route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig; errorModule?: string; loadingModule?: string; notFoundModule?: string },
2023
2111
  params: Record<string, string>,
2024
2112
  loaderData: unknown,
2025
2113
  url: string,
@@ -2039,6 +2127,30 @@ async function renderPageSSR(
2039
2127
  loaderData,
2040
2128
  });
2041
2129
 
2130
+ // Phase 18.β — per-route Suspense wrapper (Next.js `loading.tsx` parity).
2131
+ // If the route declared a `loading.tsx`, wrap the page element in a
2132
+ // `<Suspense fallback={<Loading/>}>` so async children (async server
2133
+ // components, React.lazy island hosts) can suspend without losing the
2134
+ // layout chain. Fallback import failure downgrades to render-without-
2135
+ // fallback rather than crashing the route.
2136
+ if (route.loadingModule) {
2137
+ try {
2138
+ const loadingMod = await import(path.join(settings.rootDir, route.loadingModule));
2139
+ const LoadingComponent = loadingMod.default as React.ComponentType<unknown>;
2140
+ if (typeof LoadingComponent === "function") {
2141
+ const fallback = React.createElement(LoadingComponent, {});
2142
+ app = React.createElement(React.Suspense, { fallback }, app);
2143
+ }
2144
+ } catch (loadingImportError) {
2145
+ if (settings.isDev) {
2146
+ console.warn(
2147
+ `[Mandu] loading.tsx import failed for ${route.id}; rendering without Suspense fallback:`,
2148
+ loadingImportError,
2149
+ );
2150
+ }
2151
+ }
2152
+ }
2153
+
2042
2154
  // Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
2043
2155
  // 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
2044
2156
  const needsIslandWrap =
@@ -2199,6 +2311,30 @@ async function renderPageSSR(
2199
2311
  renderError
2200
2312
  );
2201
2313
  console.error(`[Mandu] ${ssrError.errorType}:`, ssrError.message);
2314
+
2315
+ // Phase 18.α — In dev mode, emit a 500 HTML response carrying the
2316
+ // full-screen error overlay so agents + developers see a structured
2317
+ // error UI in the browser instead of only the terminal stdout.
2318
+ // The overlay payload is embedded inside the HTML and the client
2319
+ // IIFE mounts it on DOMContentLoaded. Prod paths are untouched:
2320
+ // `shouldInjectOverlay` returns `false` for any non-dev setting.
2321
+ if (shouldInjectOverlay({ isDev: settings.isDev, enabled: settings.errorOverlay })) {
2322
+ const payload = buildPayloadFromError(renderError, {
2323
+ kind: "ssr",
2324
+ routeId: route.id,
2325
+ url,
2326
+ });
2327
+ const overlayHtml = buildOverlayErrorHtml(payload);
2328
+ const res = new Response(overlayHtml, {
2329
+ status: 500,
2330
+ headers: {
2331
+ "Content-Type": "text/html; charset=utf-8",
2332
+ "Cache-Control": "no-cache, no-store, must-revalidate",
2333
+ },
2334
+ });
2335
+ return ok(cookies ? cookies.applyToResponse(res) : res);
2336
+ }
2337
+
2202
2338
  return err(ssrError);
2203
2339
  }
2204
2340
  }
@@ -2232,7 +2368,7 @@ async function readNotFoundMessage(response: Response): Promise<string> {
2232
2368
  */
2233
2369
  async function renderNotFoundPage(
2234
2370
  req: Request,
2235
- route: { id: string; pattern: string; layoutChain?: string[]; hydration?: HydrationConfig; streaming?: boolean },
2371
+ route: { id: string; pattern: string; layoutChain?: string[]; hydration?: HydrationConfig; streaming?: boolean; notFoundModule?: string },
2236
2372
  params: Record<string, string>,
2237
2373
  registry: ServerRegistry,
2238
2374
  pageCookies: CookieManager | undefined,
@@ -2244,14 +2380,42 @@ async function renderNotFoundPage(
2244
2380
  const mergedCookies = mergeCookieManagers(req, layoutCookies, pageCookies);
2245
2381
  const message = await readNotFoundMessage(notFoundResponse);
2246
2382
 
2247
- const handler = registry.notFoundHandler;
2248
- if (!handler) {
2249
- // No app/not-found.tsx registered return the existing 404 path.
2250
- return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev);
2383
+ // Phase 18.β — per-route `not-found.tsx` takes precedence over the global
2384
+ // notFoundHandler. fs-scanner has already resolved the nearest ancestor
2385
+ // at scan time (`findClosestSpecialFile`), so `route.notFoundModule` is
2386
+ // the closest match up the segment tree. Falls back to the registered
2387
+ // global handler, then to the built-in JSON 404 so broken user code
2388
+ // never tarpits the request.
2389
+ let registration: { component: React.ComponentType<Record<string, unknown>>; filling?: { hasLoader(): boolean; executeLoader(ctx: ManduContext): Promise<unknown> } } | null = null;
2390
+
2391
+ if (route.notFoundModule) {
2392
+ try {
2393
+ const mod = await import(path.join(settings.rootDir, route.notFoundModule));
2394
+ if (typeof mod.default === "function") {
2395
+ registration = { component: mod.default as React.ComponentType<Record<string, unknown>> };
2396
+ }
2397
+ } catch (importError) {
2398
+ if (settings.isDev) {
2399
+ console.warn(`[Mandu] not-found module "${route.notFoundModule}" import failed; falling back to global handler:`, importError);
2400
+ }
2401
+ }
2402
+ }
2403
+
2404
+ if (!registration) {
2405
+ const handler = registry.notFoundHandler;
2406
+ if (!handler) {
2407
+ return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev);
2408
+ }
2409
+ try {
2410
+ const globalReg = await handler();
2411
+ registration = { component: globalReg.component as React.ComponentType<Record<string, unknown>>, filling: globalReg.filling };
2412
+ } catch (handlerError) {
2413
+ console.error(`[Mandu] global notFoundHandler failed; falling back to built-in 404:`, handlerError);
2414
+ return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev);
2415
+ }
2251
2416
  }
2252
2417
 
2253
2418
  try {
2254
- const registration = await handler();
2255
2419
  const NotFoundComponent = registration.component;
2256
2420
 
2257
2421
  // Let the not-found page's own loader contribute data (e.g. nav links,
@@ -2319,7 +2483,7 @@ const pendingRevalidations = new Set<string>();
2319
2483
  async function handlePageRoute(
2320
2484
  req: Request,
2321
2485
  url: URL,
2322
- route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
2486
+ route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig; errorModule?: string; loadingModule?: string; notFoundModule?: string },
2323
2487
  params: Record<string, string>,
2324
2488
  registry: ServerRegistry
2325
2489
  ): Promise<Result<Response>> {
@@ -2533,7 +2697,7 @@ async function handlePageRoute(
2533
2697
  async function regenerateCache(
2534
2698
  req: Request,
2535
2699
  url: URL,
2536
- route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
2700
+ route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig; errorModule?: string; loadingModule?: string; notFoundModule?: string },
2537
2701
  params: Record<string, string>,
2538
2702
  registry: ServerRegistry,
2539
2703
  cache: CacheStore,
@@ -2659,11 +2823,70 @@ function buildRouteCacheKey(routeId: string, url: URL): string {
2659
2823
 
2660
2824
  /**
2661
2825
  * 메인 요청 디스패처
2826
+ *
2827
+ * `skipMiddleware` is the Phase 18.ε re-entry flag: when a composed
2828
+ * request-level middleware chain invokes its `finalHandler`, we recurse
2829
+ * into this same dispatcher with `skipMiddleware=true` so we don't run
2830
+ * the chain twice. End users never pass this — it's only set by the
2831
+ * Phase 18.ε injection block below.
2662
2832
  */
2833
+ /**
2834
+ * Phase 18 — try to serve a prerendered HTML file for `pathname`.
2835
+ *
2836
+ * Returns the HTTP response on hit, or `null` on miss (caller falls
2837
+ * through to static-file serving + SSR). Index load happens lazily
2838
+ * on the first request so `startServer` stays synchronous; failures
2839
+ * short-circuit to `null` (the feature is best-effort and must never
2840
+ * surface as a 500).
2841
+ *
2842
+ * Responses are stamped with `Cache-Control` from the registry
2843
+ * settings (default: `public, max-age=31536000, immutable`) and an
2844
+ * `X-Mandu-Cache: PRERENDERED` tag for observability / log parity
2845
+ * with the ISR cache path.
2846
+ */
2847
+ async function tryServePrerendered(
2848
+ pathname: string,
2849
+ settings: ServerRegistrySettings,
2850
+ method: string
2851
+ ): Promise<Response | null> {
2852
+ const p = settings.prerender;
2853
+ if (!p) return null;
2854
+ if (method !== "GET" && method !== "HEAD") return null;
2855
+ if (settings.edge) return null;
2856
+
2857
+ if (p.index === undefined) {
2858
+ if (!p.pending) {
2859
+ p.pending = loadPrerenderIndex(settings.rootDir, p.dir).catch(() => null);
2860
+ }
2861
+ p.index = await p.pending;
2862
+ p.pending = undefined;
2863
+ }
2864
+ if (!p.index) return null;
2865
+
2866
+ const filePath = resolvePrerenderedFile(p.index, settings.rootDir, p.dir, pathname);
2867
+ if (!filePath) return null;
2868
+
2869
+ let html: string;
2870
+ try {
2871
+ html = await fs.readFile(filePath, "utf-8");
2872
+ } catch {
2873
+ return null;
2874
+ }
2875
+
2876
+ const headers = new Headers({
2877
+ "Content-Type": "text/html; charset=utf-8",
2878
+ "Cache-Control": p.cacheControl,
2879
+ "X-Mandu-Cache": "PRERENDERED",
2880
+ });
2881
+ const body = method === "HEAD" ? null : html;
2882
+ return new Response(body, { status: 200, headers });
2883
+ }
2884
+
2663
2885
  async function handleRequestInternal(
2664
2886
  req: Request,
2665
2887
  router: Router,
2666
- registry: ServerRegistry
2888
+ registry: ServerRegistry,
2889
+ skipMiddleware: boolean = false
2667
2890
  ): Promise<Result<Response>> {
2668
2891
  const url = new URL(req.url);
2669
2892
  const pathname = url.pathname;
@@ -2675,6 +2898,19 @@ async function handleRequestInternal(
2675
2898
  return ok(handlePreflightRequest(req, corsOptions));
2676
2899
  }
2677
2900
 
2901
+ // 0.5. Phase 18 — prerendered HTML pass-through (SSG).
2902
+ // Must run BEFORE static-file serving and route dispatch so that
2903
+ // `mandu build`-emitted HTML short-circuits SSR. No-op if the
2904
+ // feature is disabled or the path wasn't prerendered.
2905
+ const prerendered = await tryServePrerendered(pathname, settings, req.method);
2906
+ if (prerendered) {
2907
+ if (settings.cors && isCorsRequest(req)) {
2908
+ const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
2909
+ return ok(applyCorsToResponse(prerendered, req, corsOptions));
2910
+ }
2911
+ return ok(prerendered);
2912
+ }
2913
+
2678
2914
  // 1. 정적 파일 서빙 시도 (최우선)
2679
2915
  // Edge runtimes (Cloudflare Workers, etc.) have no filesystem — skip and
2680
2916
  // let the platform's asset pipeline (Wrangler [assets], Vercel _static, …)
@@ -2738,6 +2974,40 @@ async function handleRequestInternal(
2738
2974
  if (kitchenResponse) return ok(kitchenResponse);
2739
2975
  }
2740
2976
 
2977
+ // ─── Phase 18.ε — canonical request-level middleware chain ───────────────
2978
+ // Runs BEFORE route dispatch, AFTER infrastructure fast-paths (static
2979
+ // files, CORS preflight, internal endpoints, γ's prerendered pass-through,
2980
+ // Kitchen). Each composed layer can short-circuit with its own Response
2981
+ // or wrap the downstream Response after `next()` returns. Zero overhead
2982
+ // when no middleware is configured (settings.middlewareChain === undefined).
2983
+ //
2984
+ // Errors inside middleware propagate to the outer `handleRequest` catch,
2985
+ // which converts them to 5xx via `errorToResponse`. Middleware authors
2986
+ // don't need their own top-level try/catch.
2987
+ //
2988
+ // Re-entry: the chain's `finalHandler` recurses into this same function
2989
+ // with `skipMiddleware=true` so the chain is not executed twice. The
2990
+ // `next(rewrittenReq)` rewrite pattern flows through transparently —
2991
+ // `finalReq` is whatever the innermost middleware asked us to dispatch.
2992
+ //
2993
+ // NOTE: intentionally contained. Does NOT alter route dispatch semantics.
2994
+ // Coexists with α's 500-path, β's dispatch, γ's prerendered check,
2995
+ // δ's island section. See `docs/architect/middleware-composition.md`.
2996
+ if (!skipMiddleware && settings.middlewareChain) {
2997
+ const composed = settings.middlewareChain;
2998
+ const dispatchRoute = async (finalReq: Request): Promise<Response> => {
2999
+ const result = await handleRequestInternal(finalReq, router, registry, true);
3000
+ if (result.ok) return result.value;
3001
+ // Surface error-path responses to the chain so logging / metrics
3002
+ // layers see the final status. The outer `handleRequest` still owns
3003
+ // dev-mode Cache-Control stamping + eventBus emission.
3004
+ return errorToResponse(result.error, settings.isDev);
3005
+ };
3006
+ const composedResponse = await composed(req, dispatchRoute);
3007
+ return ok(composedResponse);
3008
+ }
3009
+ // ─── End Phase 18.ε ──────────────────────────────────────────────────────
3010
+
2741
3011
  // 3. 라우트 매칭
2742
3012
  const match = router.match(pathname);
2743
3013
  if (!match) {
@@ -2934,8 +3204,36 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2934
3204
  spa,
2935
3205
  devtools,
2936
3206
  observability: observabilityOption,
3207
+ prerender: prerenderOption,
3208
+ middleware: middlewareOption,
2937
3209
  } = options;
2938
3210
 
3211
+ // Phase 18.ε — build the request-level middleware chain once at boot.
3212
+ // `compose()` returns a passthrough when the list is empty; storing
3213
+ // `undefined` for "no middleware" keeps the hot path branch-free.
3214
+ const middlewareChain: ComposedHandler | undefined =
3215
+ middlewareOption && middlewareOption.length > 0
3216
+ ? composeMiddleware(...middlewareOption)
3217
+ : undefined;
3218
+
3219
+ // Phase 18 — normalize prerender pass-through settings. `undefined`
3220
+ // defaults to enabled (Next.js parity); explicit `false` opts out.
3221
+ let prerenderSettings: ServerRegistrySettings["prerender"] | undefined;
3222
+ if (prerenderOption !== false) {
3223
+ const dirOption =
3224
+ typeof prerenderOption === "object" && prerenderOption?.dir
3225
+ ? prerenderOption.dir
3226
+ : DEFAULT_PRERENDER_DIR;
3227
+ const cacheControl =
3228
+ typeof prerenderOption === "object" && prerenderOption?.cacheControl
3229
+ ? prerenderOption.cacheControl
3230
+ : DEFAULT_PRERENDER_CACHE_CONTROL;
3231
+ const absoluteDir = path.isAbsolute(dirOption)
3232
+ ? dirOption
3233
+ : path.join(options.rootDir ?? process.cwd(), dirOption);
3234
+ prerenderSettings = { dir: absoluteDir, cacheControl };
3235
+ }
3236
+
2939
3237
  // cssPath 처리:
2940
3238
  // - string: 해당 경로로 <link> 주입
2941
3239
  // - false: CSS 링크 주입 비활성화
@@ -2975,6 +3273,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2975
3273
  devtools,
2976
3274
  heapEndpoint: observabilityOption?.heapEndpoint,
2977
3275
  metricsEndpoint: observabilityOption?.metricsEndpoint,
3276
+ prerender: prerenderSettings,
3277
+ middlewareChain,
2978
3278
  };
2979
3279
 
2980
3280
  registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
@@ -11,6 +11,7 @@ import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
11
  import { generateFastRefreshPreamble } from "../bundler/dev";
12
12
  import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
13
13
  import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
14
+ import { maybeInjectDevOverlay } from "../dev-error-overlay";
14
15
 
15
16
  /**
16
17
  * Issue #192 — `@view-transition` at-rule block.
@@ -136,6 +137,20 @@ export interface SSROptions {
136
137
  * flag is a no-op in prod regardless of value).
137
138
  */
138
139
  devtools?: boolean;
140
+ /**
141
+ * Phase 18.α — Dev error overlay opt-out. When `true` (default) dev-mode
142
+ * SSR responses receive an inlined `<style>` + `<script>` block that
143
+ * renders a Next.js-style full-screen overlay for:
144
+ * - uncaught `window.onerror` events
145
+ * - `unhandledrejection` Promise rejections
146
+ * - custom `__MANDU_ERROR__` CustomEvent dispatches (used by the 500
147
+ * response path to surface SSR render failures)
148
+ *
149
+ * Wired from `ManduConfig.dev.errorOverlay`. Prod builds NEVER emit
150
+ * the overlay — `shouldInjectOverlay()` triple-gates against
151
+ * `NODE_ENV=production`, `isDev=false`, and explicit opt-out.
152
+ */
153
+ devErrorOverlay?: boolean;
139
154
  }
140
155
 
141
156
  let projectRenderToString: ((element: ReactElement) => string) | null | undefined;
@@ -255,16 +270,56 @@ function generateHydrationScripts(
255
270
  /**
256
271
  * Island 래퍼로 컨텐츠 감싸기
257
272
  * v0.8.0: data-mandu-src 속성 추가 (Runtime이 dynamic import로 로드)
273
+ *
274
+ * Phase 18.δ — emit `data-hydrate` alongside legacy `data-mandu-priority`.
275
+ * The new attribute is the canonical per-island hydration strategy spec
276
+ * read by `packages/core/src/client/hydrate.ts::parseHydrateStrategy`.
277
+ *
278
+ * Mapping of legacy priorities → Astro-grade strategies:
279
+ * - `immediate` → `load` (hydrate right away)
280
+ * - `visible` → `visible` (IntersectionObserver, 200px rootMargin)
281
+ * - `idle` → `idle` (requestIdleCallback)
282
+ * - `interaction` → `interaction` (click/touchstart/keydown)
283
+ *
284
+ * `hydrate` (the formalized strategy spec, including `media(<query>)`)
285
+ * takes precedence when provided; otherwise we derive `data-hydrate` from
286
+ * `priority` for backward compatibility. `data-mandu-priority` stays so
287
+ * existing bundler-generated runtimes and dev-tools keep working.
258
288
  */
259
289
  export function wrapWithIsland(
260
290
  content: string,
261
291
  routeId: string,
262
292
  priority: HydrationPriority = "visible",
263
- bundleSrc?: string
293
+ bundleSrc?: string,
294
+ hydrate?: string
264
295
  ): string {
265
296
  const cacheBustedSrc = bundleSrc ? `${bundleSrc}?t=${Date.now()}` : undefined;
266
297
  const srcAttr = cacheBustedSrc ? ` data-mandu-src="${escapeHtmlAttr(cacheBustedSrc)}"` : "";
267
- return `<div data-mandu-island="${escapeHtmlAttr(routeId)}"${srcAttr} data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">${content}</div>`;
298
+ const hydrateValue = hydrate ?? priorityToHydrateStrategy(priority);
299
+ const hydrateAttr = ` data-hydrate="${escapeHtmlAttr(hydrateValue)}"`;
300
+ return `<div data-mandu-island="${escapeHtmlAttr(routeId)}"${srcAttr} data-mandu-priority="${escapeHtmlAttr(priority)}"${hydrateAttr} style="display:contents">${content}</div>`;
301
+ }
302
+
303
+ /**
304
+ * Legacy `HydrationPriority` → Phase 18.δ `data-hydrate` strategy name.
305
+ *
306
+ * Kept local to ssr.ts (no public export) because the mapping is purely an
307
+ * attribute-emission concern; callers that know the new spec pass `hydrate`
308
+ * directly to `wrapWithIsland`.
309
+ */
310
+ function priorityToHydrateStrategy(priority: HydrationPriority): string {
311
+ switch (priority) {
312
+ case "immediate":
313
+ return "load";
314
+ case "visible":
315
+ return "visible";
316
+ case "idle":
317
+ return "idle";
318
+ case "interaction":
319
+ return "interaction";
320
+ default:
321
+ return "load";
322
+ }
268
323
  }
269
324
 
270
325
  /**
@@ -558,6 +613,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
558
613
  prefetch = true,
559
614
  spa,
560
615
  devtools,
616
+ devErrorOverlay,
561
617
  } = options;
562
618
 
563
619
  // CSS 링크 태그 생성
@@ -711,6 +767,17 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
711
767
  });
712
768
  const hoistedLinkTags = hoistedLinks.join("\n ");
713
769
 
770
+ // Phase 18.α — Dev Error Overlay injection.
771
+ // Only emitted when `isDev` AND the user has not opted out (via
772
+ // `ManduConfig.dev.errorOverlay: false` → `devErrorOverlay: false`).
773
+ // The injector itself re-checks `NODE_ENV !== "production"` as a
774
+ // belt-and-suspenders guard, so prod HTML is byte-identical
775
+ // regardless of whether this option is wired.
776
+ const devErrorOverlayTag = maybeInjectDevOverlay({
777
+ isDev,
778
+ enabled: devErrorOverlay,
779
+ });
780
+
714
781
  return `<!doctype html>
715
782
  <html lang="${escapeHtmlAttr(lang)}">
716
783
  <head>
@@ -725,6 +792,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
725
792
  ${headTags}
726
793
  ${collectedHeadTags}
727
794
  ${fastRefreshPreamble}
795
+ ${devErrorOverlayTag}
728
796
  </head>
729
797
  <body>
730
798
  <div id="root">${bodyContent}</div>
@@ -92,6 +92,7 @@ export const PageRouteSpec = z
92
92
  layoutChain: z.array(z.string()).optional(),
93
93
  loadingModule: z.string().optional(),
94
94
  errorModule: z.string().optional(),
95
+ notFoundModule: z.string().optional(),
95
96
  })
96
97
  .refine(
97
98
  (route) => {
@@ -119,6 +120,7 @@ export const ApiRouteSpec = z.object({
119
120
  layoutChain: z.array(z.string()).optional(),
120
121
  loadingModule: z.string().optional(),
121
122
  errorModule: z.string().optional(),
123
+ notFoundModule: z.string().optional(),
122
124
  });
123
125
 
124
126
  export type ApiRouteSpec = z.infer<typeof ApiRouteSpec>;
@@ -140,6 +142,7 @@ export const MetadataRouteSpec = z.object({
140
142
  layoutChain: z.array(z.string()).optional(),
141
143
  loadingModule: z.string().optional(),
142
144
  errorModule: z.string().optional(),
145
+ notFoundModule: z.string().optional(),
143
146
  });
144
147
 
145
148
  export type MetadataRouteSpec = z.infer<typeof MetadataRouteSpec>;
@@ -155,6 +158,7 @@ export const RouteSpec = z.discriminatedUnion("kind", [
155
158
  layoutChain: z.array(z.string()).optional(),
156
159
  loadingModule: z.string().optional(),
157
160
  errorModule: z.string().optional(),
161
+ notFoundModule: z.string().optional(),
158
162
  }),
159
163
  z.object({
160
164
  ...RouteSpecBase,
@@ -164,6 +168,7 @@ export const RouteSpec = z.discriminatedUnion("kind", [
164
168
  layoutChain: z.array(z.string()).optional(),
165
169
  loadingModule: z.string().optional(),
166
170
  errorModule: z.string().optional(),
171
+ notFoundModule: z.string().optional(),
167
172
  }),
168
173
  z.object({
169
174
  ...RouteSpecBase,
@@ -175,6 +180,7 @@ export const RouteSpec = z.discriminatedUnion("kind", [
175
180
  layoutChain: z.array(z.string()).optional(),
176
181
  loadingModule: z.string().optional(),
177
182
  errorModule: z.string().optional(),
183
+ notFoundModule: z.string().optional(),
178
184
  }),
179
185
  ]);
180
186