@mandujs/core 0.28.0 → 0.29.1
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 +9 -1
- package/src/bundler/generate-static-params.ts +290 -0
- package/src/bundler/prerender.ts +242 -69
- package/src/client/hydrate.ts +340 -0
- package/src/client/index.ts +11 -0
- package/src/config/mandu.ts +40 -0
- package/src/config/validate.ts +36 -0
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -0
- package/src/dev-error-overlay/index.ts +30 -0
- package/src/dev-error-overlay/overlay-client.ts +300 -0
- package/src/dev-error-overlay/overlay-injector.ts +243 -0
- package/src/dev-error-overlay/overlay-styles.ts +52 -0
- package/src/dev-error-overlay/types.ts +66 -0
- package/src/middleware/bridge.ts +147 -0
- package/src/middleware/compose.ts +134 -0
- package/src/middleware/define.ts +132 -0
- package/src/middleware/index.ts +76 -50
- package/src/router/fs-patterns.test.ts +96 -0
- package/src/router/fs-routes.ts +1 -0
- package/src/router/fs-scanner.ts +10 -1
- package/src/router/fs-types.ts +8 -0
- package/src/runtime/server.ts +310 -10
- package/src/runtime/ssr.ts +70 -2
- package/src/spec/schema.ts +6 -0
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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;
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/src/spec/schema.ts
CHANGED
|
@@ -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
|
|