@mandujs/core 0.19.2 → 0.20.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 +1 -1
- package/src/bundler/build.ts +94 -2
- package/src/bundler/css.ts +323 -353
- package/src/bundler/types.ts +20 -0
- package/src/devtools/client/components/mandu-character.tsx +77 -53
- package/src/devtools/client/components/panel/errors-panel.tsx +2 -2
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -31
- package/src/devtools/client/components/panel/islands-panel.tsx +30 -14
- package/src/devtools/client/components/panel/network-panel.tsx +2 -3
- package/src/devtools/client/components/panel/panel-container.tsx +485 -332
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -22
- package/src/devtools/init.ts +1 -1
- package/src/devtools/types.ts +35 -35
- package/src/filling/filling.ts +66 -66
- package/src/index.ts +1 -0
- package/src/kitchen/kitchen-handler.ts +80 -1
- package/src/observability/event-bus.ts +79 -0
- package/src/observability/index.ts +8 -0
- package/src/observability/logger-adapter.ts +36 -0
- package/src/runtime/index.ts +9 -8
- package/src/runtime/ppr.ts +74 -0
- package/src/runtime/server.ts +203 -147
- package/src/runtime/ssr.ts +17 -4
- package/src/runtime/streaming-ssr.ts +55 -36
- package/src/testing/index.ts +45 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Partial Prerendering (PPR)
|
|
3
|
+
*
|
|
4
|
+
* Caches the static HTML shell (header, sidebar, layout) at build/first-request time,
|
|
5
|
+
* then injects fresh dynamic data (loader results) per request.
|
|
6
|
+
*
|
|
7
|
+
* Result: TTFB of a static page + freshness of a dynamic page.
|
|
8
|
+
*
|
|
9
|
+
* The shell is the expensive part (React render tree traversal).
|
|
10
|
+
* Data injection is cheap (JSON serialization into a script tag).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { escapeJsonForInlineScript } from "./escape";
|
|
14
|
+
import { serializeProps } from "../client/serialize";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* PPR shell marker injected at the end of cached HTML.
|
|
18
|
+
* Everything before this marker is the static shell; everything after
|
|
19
|
+
* (closing tags, data scripts) is regenerated per request.
|
|
20
|
+
*/
|
|
21
|
+
export const PPR_SHELL_MARKER = "<!--mandu:ppr-split-->";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Strip the closing </body></html> and any trailing whitespace from
|
|
25
|
+
* a full SSR HTML string, then append the shell marker.
|
|
26
|
+
* The result is safe to cache and later concatenated with fresh data.
|
|
27
|
+
*/
|
|
28
|
+
export function extractShellHtml(fullHtml: string): string {
|
|
29
|
+
// Remove trailing </body></html> (case-insensitive, whitespace-tolerant)
|
|
30
|
+
const trimmed = fullHtml.replace(/<\/body>\s*<\/html>\s*$/i, "");
|
|
31
|
+
return trimmed + PPR_SHELL_MARKER;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a streaming Response that:
|
|
36
|
+
* 1. Sends the cached shell immediately (near-zero TTFB)
|
|
37
|
+
* 2. Appends a script tag with fresh loader data
|
|
38
|
+
* 3. Closes with </body></html>
|
|
39
|
+
*/
|
|
40
|
+
export function createPPRResponse(
|
|
41
|
+
shellHtml: string,
|
|
42
|
+
routeId: string,
|
|
43
|
+
loaderData: unknown,
|
|
44
|
+
): Response {
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
|
|
47
|
+
// Build the data payload once; avoid doing work inside the stream callback
|
|
48
|
+
const serialized = serializeProps({ serverData: loaderData });
|
|
49
|
+
const escaped = escapeJsonForInlineScript(serialized);
|
|
50
|
+
const dataScript =
|
|
51
|
+
`<script>window.__MANDU_DATA__=window.__MANDU_DATA__||{};` +
|
|
52
|
+
`window.__MANDU_DATA__[${JSON.stringify(routeId)}]=${escaped}</script>`;
|
|
53
|
+
|
|
54
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
55
|
+
start(controller) {
|
|
56
|
+
// (a) Cached shell -- everything up to the split marker (inclusive)
|
|
57
|
+
controller.enqueue(encoder.encode(shellHtml));
|
|
58
|
+
// (b) Fresh data script
|
|
59
|
+
controller.enqueue(encoder.encode(dataScript));
|
|
60
|
+
// (c) Close the document
|
|
61
|
+
controller.enqueue(encoder.encode("</body></html>"));
|
|
62
|
+
controller.close();
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return new Response(stream, {
|
|
67
|
+
status: 200,
|
|
68
|
+
headers: {
|
|
69
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
70
|
+
"X-Content-Type-Options": "nosniff",
|
|
71
|
+
"X-Mandu-PPR": "shell-hit",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -10,17 +10,17 @@ import React, { type ReactNode } from "react";
|
|
|
10
10
|
import path from "path";
|
|
11
11
|
import fs from "fs/promises";
|
|
12
12
|
import { PORTS } from "../constants";
|
|
13
|
-
import {
|
|
14
|
-
type CacheStore,
|
|
15
|
-
type CacheStoreStats,
|
|
16
|
-
type CacheLookupResult,
|
|
17
|
-
MemoryCacheStore,
|
|
18
|
-
lookupCache,
|
|
19
|
-
createCacheEntry,
|
|
20
|
-
createCachedResponse,
|
|
21
|
-
getCacheStoreStats,
|
|
22
|
-
setGlobalCache,
|
|
23
|
-
} from "./cache";
|
|
13
|
+
import {
|
|
14
|
+
type CacheStore,
|
|
15
|
+
type CacheStoreStats,
|
|
16
|
+
type CacheLookupResult,
|
|
17
|
+
MemoryCacheStore,
|
|
18
|
+
lookupCache,
|
|
19
|
+
createCacheEntry,
|
|
20
|
+
createCachedResponse,
|
|
21
|
+
getCacheStoreStats,
|
|
22
|
+
setGlobalCache,
|
|
23
|
+
} from "./cache";
|
|
24
24
|
import {
|
|
25
25
|
createNotFoundResponse,
|
|
26
26
|
createHandlerNotFoundResponse,
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
isCorsRequest,
|
|
40
40
|
} from "./cors";
|
|
41
41
|
import { validateImportPath } from "./security";
|
|
42
|
-
import { KITCHEN_PREFIX, KitchenHandler } from "../kitchen/kitchen-handler";
|
|
42
|
+
import { KITCHEN_PREFIX, KitchenHandler, recordRequest } from "../kitchen/kitchen-handler";
|
|
43
43
|
import {
|
|
44
44
|
type MiddlewareFn,
|
|
45
45
|
type MiddlewareConfig,
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { createFetchHandler } from "./handler";
|
|
49
49
|
import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
|
|
50
50
|
import { handleImageRequest } from "./image-handler";
|
|
51
|
+
import { extractShellHtml, createPPRResponse } from "./ppr";
|
|
51
52
|
|
|
52
53
|
export interface RateLimitOptions {
|
|
53
54
|
windowMs?: number;
|
|
@@ -278,7 +279,7 @@ function getMimeType(filePath: string): string {
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
// ========== Server Options ==========
|
|
281
|
-
export interface ServerOptions {
|
|
282
|
+
export interface ServerOptions {
|
|
282
283
|
port?: number;
|
|
283
284
|
hostname?: string;
|
|
284
285
|
/** 프로젝트 루트 디렉토리 */
|
|
@@ -331,13 +332,13 @@ export interface ServerOptions {
|
|
|
331
332
|
* - CacheStore: 커스텀 캐시 구현체
|
|
332
333
|
* - false/undefined: 캐시 비활성화
|
|
333
334
|
*/
|
|
334
|
-
cache?: boolean | CacheStore;
|
|
335
|
-
/**
|
|
336
|
-
* Internal management token for local CLI/runtime control endpoints.
|
|
337
|
-
* When set, token-protected endpoints such as `/_mandu/cache` become available.
|
|
338
|
-
*/
|
|
339
|
-
managementToken?: string;
|
|
340
|
-
}
|
|
335
|
+
cache?: boolean | CacheStore;
|
|
336
|
+
/**
|
|
337
|
+
* Internal management token for local CLI/runtime control endpoints.
|
|
338
|
+
* When set, token-protected endpoints such as `/_mandu/cache` become available.
|
|
339
|
+
*/
|
|
340
|
+
managementToken?: string;
|
|
341
|
+
}
|
|
341
342
|
|
|
342
343
|
export interface ManduServer {
|
|
343
344
|
server: Server<undefined>;
|
|
@@ -428,11 +429,11 @@ export interface ServerRegistrySettings {
|
|
|
428
429
|
* - undefined: false로 처리 (404 방지)
|
|
429
430
|
*/
|
|
430
431
|
cssPath?: string | false;
|
|
431
|
-
/** ISR/SWR 캐시 스토어 */
|
|
432
|
-
cacheStore?: CacheStore;
|
|
433
|
-
/** Internal management token for local runtime control */
|
|
434
|
-
managementToken?: string;
|
|
435
|
-
}
|
|
432
|
+
/** ISR/SWR 캐시 스토어 */
|
|
433
|
+
cacheStore?: CacheStore;
|
|
434
|
+
/** Internal management token for local runtime control */
|
|
435
|
+
managementToken?: string;
|
|
436
|
+
}
|
|
436
437
|
|
|
437
438
|
export class ServerRegistry {
|
|
438
439
|
readonly apiHandlers: Map<string, ApiHandler> = new Map();
|
|
@@ -752,12 +753,12 @@ function createDefaultAppFactory(registry: ServerRegistry) {
|
|
|
752
753
|
|
|
753
754
|
// ========== Static File Serving ==========
|
|
754
755
|
|
|
755
|
-
interface StaticFileResult {
|
|
756
|
-
handled: boolean;
|
|
757
|
-
response?: Response;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
756
|
+
interface StaticFileResult {
|
|
757
|
+
handled: boolean;
|
|
758
|
+
response?: Response;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
761
762
|
|
|
762
763
|
function createStaticErrorResponse(status: 400 | 403 | 404 | 500): Response {
|
|
763
764
|
const body = {
|
|
@@ -811,7 +812,7 @@ async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean
|
|
|
811
812
|
*
|
|
812
813
|
* 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
|
|
813
814
|
*/
|
|
814
|
-
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings, request?: Request): Promise<StaticFileResult> {
|
|
815
|
+
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings, request?: Request): Promise<StaticFileResult> {
|
|
815
816
|
let filePath: string | null = null;
|
|
816
817
|
let isBundleFile = false;
|
|
817
818
|
let allowedBaseDir: string;
|
|
@@ -924,100 +925,124 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
924
925
|
} catch {
|
|
925
926
|
return { handled: true, response: createStaticErrorResponse(500) };
|
|
926
927
|
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
// ========== Request Handler ==========
|
|
930
|
-
|
|
931
|
-
function unauthorizedControlResponse(): Response {
|
|
932
|
-
return Response.json({ error: "Unauthorized runtime control request" }, { status: 401 });
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
function resolveInternalCacheTarget(payload: Record<string, unknown>): string {
|
|
936
|
-
if (typeof payload.path === "string" && payload.path.length > 0) {
|
|
937
|
-
return `path=${payload.path}`;
|
|
938
|
-
}
|
|
939
|
-
if (typeof payload.tag === "string" && payload.tag.length > 0) {
|
|
940
|
-
return `tag=${payload.tag}`;
|
|
941
|
-
}
|
|
942
|
-
if (payload.all === true) {
|
|
943
|
-
return "all";
|
|
944
|
-
}
|
|
945
|
-
return "unknown";
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
async function handleInternalCacheControlRequest(
|
|
949
|
-
req: Request,
|
|
950
|
-
settings: ServerRegistrySettings
|
|
951
|
-
): Promise<Response> {
|
|
952
|
-
const expectedToken = settings.managementToken;
|
|
953
|
-
const providedToken = req.headers.get("x-mandu-control-token");
|
|
954
|
-
|
|
955
|
-
if (!expectedToken || providedToken !== expectedToken) {
|
|
956
|
-
return unauthorizedControlResponse();
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
const store = settings.cacheStore ?? null;
|
|
960
|
-
if (!store) {
|
|
961
|
-
return Response.json({
|
|
962
|
-
enabled: false,
|
|
963
|
-
message: "Runtime cache is disabled for this server instance.",
|
|
964
|
-
stats: null,
|
|
965
|
-
});
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
if (req.method === "GET") {
|
|
969
|
-
const stats = getCacheStoreStats(store);
|
|
970
|
-
return Response.json({
|
|
971
|
-
enabled: true,
|
|
972
|
-
message: "Runtime cache is available.",
|
|
973
|
-
stats,
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
if (req.method === "POST" || req.method === "DELETE") {
|
|
978
|
-
let payload: Record<string, unknown> = {};
|
|
979
|
-
if (req.method === "POST") {
|
|
980
|
-
try {
|
|
981
|
-
payload = await req.json() as Record<string, unknown>;
|
|
982
|
-
} catch {
|
|
983
|
-
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
984
|
-
}
|
|
985
|
-
} else {
|
|
986
|
-
payload = { all: true };
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
const before = store.size;
|
|
990
|
-
if (typeof payload.path === "string" && payload.path.length > 0) {
|
|
991
|
-
store.deleteByPath(payload.path);
|
|
992
|
-
} else if (typeof payload.tag === "string" && payload.tag.length > 0) {
|
|
993
|
-
store.deleteByTag(payload.tag);
|
|
994
|
-
} else if (payload.all === true) {
|
|
995
|
-
store.clear();
|
|
996
|
-
} else {
|
|
997
|
-
return Response.json({
|
|
998
|
-
error: "Provide one of: { path }, { tag }, or { all: true }",
|
|
999
|
-
}, { status: 400 });
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
const after = store.size;
|
|
1003
|
-
const stats: CacheStoreStats | null = getCacheStoreStats(store);
|
|
1004
|
-
|
|
1005
|
-
return Response.json({
|
|
1006
|
-
enabled: true,
|
|
1007
|
-
cleared: Math.max(0, before - after),
|
|
1008
|
-
target: resolveInternalCacheTarget(payload),
|
|
1009
|
-
stats,
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ========== Request Handler ==========
|
|
931
|
+
|
|
932
|
+
function unauthorizedControlResponse(): Response {
|
|
933
|
+
return Response.json({ error: "Unauthorized runtime control request" }, { status: 401 });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function resolveInternalCacheTarget(payload: Record<string, unknown>): string {
|
|
937
|
+
if (typeof payload.path === "string" && payload.path.length > 0) {
|
|
938
|
+
return `path=${payload.path}`;
|
|
939
|
+
}
|
|
940
|
+
if (typeof payload.tag === "string" && payload.tag.length > 0) {
|
|
941
|
+
return `tag=${payload.tag}`;
|
|
942
|
+
}
|
|
943
|
+
if (payload.all === true) {
|
|
944
|
+
return "all";
|
|
945
|
+
}
|
|
946
|
+
return "unknown";
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function handleInternalCacheControlRequest(
|
|
950
|
+
req: Request,
|
|
951
|
+
settings: ServerRegistrySettings
|
|
952
|
+
): Promise<Response> {
|
|
953
|
+
const expectedToken = settings.managementToken;
|
|
954
|
+
const providedToken = req.headers.get("x-mandu-control-token");
|
|
955
|
+
|
|
956
|
+
if (!expectedToken || providedToken !== expectedToken) {
|
|
957
|
+
return unauthorizedControlResponse();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const store = settings.cacheStore ?? null;
|
|
961
|
+
if (!store) {
|
|
962
|
+
return Response.json({
|
|
963
|
+
enabled: false,
|
|
964
|
+
message: "Runtime cache is disabled for this server instance.",
|
|
965
|
+
stats: null,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (req.method === "GET") {
|
|
970
|
+
const stats = getCacheStoreStats(store);
|
|
971
|
+
return Response.json({
|
|
972
|
+
enabled: true,
|
|
973
|
+
message: "Runtime cache is available.",
|
|
974
|
+
stats,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (req.method === "POST" || req.method === "DELETE") {
|
|
979
|
+
let payload: Record<string, unknown> = {};
|
|
980
|
+
if (req.method === "POST") {
|
|
981
|
+
try {
|
|
982
|
+
payload = await req.json() as Record<string, unknown>;
|
|
983
|
+
} catch {
|
|
984
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
985
|
+
}
|
|
986
|
+
} else {
|
|
987
|
+
payload = { all: true };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const before = store.size;
|
|
991
|
+
if (typeof payload.path === "string" && payload.path.length > 0) {
|
|
992
|
+
store.deleteByPath(payload.path);
|
|
993
|
+
} else if (typeof payload.tag === "string" && payload.tag.length > 0) {
|
|
994
|
+
store.deleteByTag(payload.tag);
|
|
995
|
+
} else if (payload.all === true) {
|
|
996
|
+
store.clear();
|
|
997
|
+
} else {
|
|
998
|
+
return Response.json({
|
|
999
|
+
error: "Provide one of: { path }, { tag }, or { all: true }",
|
|
1000
|
+
}, { status: 400 });
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const after = store.size;
|
|
1004
|
+
const stats: CacheStoreStats | null = getCacheStoreStats(store);
|
|
1005
|
+
|
|
1006
|
+
return Response.json({
|
|
1007
|
+
enabled: true,
|
|
1008
|
+
cleared: Math.max(0, before - after),
|
|
1009
|
+
target: resolveInternalCacheTarget(payload),
|
|
1010
|
+
stats,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
|
|
1018
|
+
const requestStart = Date.now();
|
|
1017
1019
|
const result = await handleRequestInternal(req, router, registry);
|
|
1018
1020
|
|
|
1019
1021
|
if (!result.ok) {
|
|
1020
|
-
|
|
1022
|
+
const errorResponse = errorToResponse(result.error, registry.settings.isDev);
|
|
1023
|
+
if (registry.settings.isDev) {
|
|
1024
|
+
const url = new URL(req.url);
|
|
1025
|
+
const p = url.pathname;
|
|
1026
|
+
if (!p.startsWith("/.mandu/") && !p.startsWith("/__kitchen")) {
|
|
1027
|
+
const elapsed = Date.now() - requestStart;
|
|
1028
|
+
console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${p} ${errorResponse.status} ${elapsed}ms`);
|
|
1029
|
+
recordRequest({ id: crypto.randomUUID(), method: req.method, path: p, status: errorResponse.status, duration: elapsed, timestamp: Date.now() });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return errorResponse;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (registry.settings.isDev) {
|
|
1036
|
+
const url = new URL(req.url);
|
|
1037
|
+
const p = url.pathname;
|
|
1038
|
+
if (!p.startsWith("/.mandu/") && !p.startsWith("/__kitchen")) {
|
|
1039
|
+
const elapsed = Date.now() - requestStart;
|
|
1040
|
+
const status = result.value.status;
|
|
1041
|
+
const cacheHdr = result.value.headers.get("X-Mandu-Cache") ?? "";
|
|
1042
|
+
const cacheTag = cacheHdr ? ` ${cacheHdr}` : "";
|
|
1043
|
+
console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${p} ${status} ${elapsed}ms${cacheTag}`);
|
|
1044
|
+
recordRequest({ id: crypto.randomUUID(), method: req.method, path: p, status, duration: elapsed, timestamp: Date.now(), cacheStatus: cacheHdr || undefined });
|
|
1045
|
+
}
|
|
1021
1046
|
}
|
|
1022
1047
|
|
|
1023
1048
|
return result.value;
|
|
@@ -1399,8 +1424,25 @@ async function handlePageRoute(
|
|
|
1399
1424
|
// _data 요청 (SPA 네비게이션)은 캐시하지 않음
|
|
1400
1425
|
const isDataRequest = url.searchParams.has("_data");
|
|
1401
1426
|
|
|
1427
|
+
// PPR: cached shell + fresh dynamic data per request
|
|
1428
|
+
if (renderMode === "ppr" && cache && !isDataRequest) {
|
|
1429
|
+
const shellCacheKey = `ppr-shell:${route.id}`;
|
|
1430
|
+
const cachedShell = cache.get(shellCacheKey);
|
|
1431
|
+
|
|
1432
|
+
if (cachedShell) {
|
|
1433
|
+
// Shell HIT: load only the dynamic data (cheap), skip full SSR render
|
|
1434
|
+
const loadResult = await loadPageData(req, route, params, registry);
|
|
1435
|
+
if (!loadResult.ok) return loadResult;
|
|
1436
|
+
const { loaderData, cookies } = loadResult.value;
|
|
1437
|
+
const pprResponse = createPPRResponse(cachedShell.html, route.id, loaderData);
|
|
1438
|
+
return ok(cookies ? cookies.applyToResponse(pprResponse) : pprResponse);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Shell MISS: fall through to full render, then cache the shell below
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1402
1444
|
// ISR/SWR 캐시 확인 (SSR 렌더링 요청에만 적용)
|
|
1403
|
-
if (cache && !isDataRequest && renderMode !== "dynamic") {
|
|
1445
|
+
if (cache && !isDataRequest && renderMode !== "dynamic" && renderMode !== "ppr") {
|
|
1404
1446
|
const cacheKey = buildRouteCacheKey(route.id, url);
|
|
1405
1447
|
const lookup = lookupCache(cache, cacheKey);
|
|
1406
1448
|
|
|
@@ -1454,8 +1496,22 @@ async function handlePageRoute(
|
|
|
1454
1496
|
// 3. SSR 렌더링 (layoutData 전달)
|
|
1455
1497
|
const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, cookies, layoutData);
|
|
1456
1498
|
|
|
1457
|
-
//
|
|
1458
|
-
if (cache && ssrResult.ok && renderMode
|
|
1499
|
+
// 4a. PPR: cache only the shell (HTML structure minus loader data), not the full page
|
|
1500
|
+
if (cache && ssrResult.ok && renderMode === "ppr") {
|
|
1501
|
+
const cacheOptions = getCacheOptionsForRoute(route.id, registry);
|
|
1502
|
+
const revalidate = cacheOptions?.revalidate ?? 3600; // default 1 hour for PPR shells
|
|
1503
|
+
const shellCacheKey = `ppr-shell:${route.id}`;
|
|
1504
|
+
const cloned = ssrResult.value.clone();
|
|
1505
|
+
cloned.text().then((html) => {
|
|
1506
|
+
const shellHtml = extractShellHtml(html);
|
|
1507
|
+
cache.set(shellCacheKey, createCacheEntry(
|
|
1508
|
+
shellHtml, null, revalidate, cacheOptions?.tags ?? []
|
|
1509
|
+
));
|
|
1510
|
+
}).catch(() => {});
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// 4b. ISR/SWR 캐시 저장 (revalidate 설정이 있는 경우 — non-blocking)
|
|
1514
|
+
if (cache && ssrResult.ok && renderMode !== "dynamic" && renderMode !== "ppr") {
|
|
1459
1515
|
const cacheOptions = getCacheOptionsForRoute(route.id, registry);
|
|
1460
1516
|
if (cacheOptions?.revalidate && cacheOptions.revalidate > 0) {
|
|
1461
1517
|
const cloned = ssrResult.value.clone();
|
|
@@ -1605,18 +1661,18 @@ async function handleRequestInternal(
|
|
|
1605
1661
|
return ok(staticResponse);
|
|
1606
1662
|
}
|
|
1607
1663
|
|
|
1608
|
-
// 1.5. Image optimization handler (/_mandu/image)
|
|
1609
|
-
if (pathname === "/_mandu/image") {
|
|
1610
|
-
const imageResponse = await handleImageRequest(req, settings.rootDir, settings.publicDir);
|
|
1611
|
-
if (imageResponse) return ok(imageResponse);
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
// 1.6. Internal runtime cache control endpoint
|
|
1615
|
-
if (pathname === INTERNAL_CACHE_ENDPOINT) {
|
|
1616
|
-
return ok(await handleInternalCacheControlRequest(req, settings));
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
// 2. Kitchen dev dashboard (dev mode only)
|
|
1664
|
+
// 1.5. Image optimization handler (/_mandu/image)
|
|
1665
|
+
if (pathname === "/_mandu/image") {
|
|
1666
|
+
const imageResponse = await handleImageRequest(req, settings.rootDir, settings.publicDir);
|
|
1667
|
+
if (imageResponse) return ok(imageResponse);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// 1.6. Internal runtime cache control endpoint
|
|
1671
|
+
if (pathname === INTERNAL_CACHE_ENDPOINT) {
|
|
1672
|
+
return ok(await handleInternalCacheControlRequest(req, settings));
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// 2. Kitchen dev dashboard (dev mode only)
|
|
1620
1676
|
if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
|
|
1621
1677
|
const kitchenResponse = await registry.kitchen.handle(req, pathname);
|
|
1622
1678
|
if (kitchenResponse) return ok(kitchenResponse);
|
|
@@ -1732,10 +1788,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1732
1788
|
rateLimit = false,
|
|
1733
1789
|
cssPath: cssPathOption,
|
|
1734
1790
|
registry = defaultRegistry,
|
|
1735
|
-
guardConfig = null,
|
|
1736
|
-
cache: cacheOption,
|
|
1737
|
-
managementToken,
|
|
1738
|
-
} = options;
|
|
1791
|
+
guardConfig = null,
|
|
1792
|
+
cache: cacheOption,
|
|
1793
|
+
managementToken,
|
|
1794
|
+
} = options;
|
|
1739
1795
|
|
|
1740
1796
|
// cssPath 처리:
|
|
1741
1797
|
// - string: 해당 경로로 <link> 주입
|
|
@@ -1766,11 +1822,11 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1766
1822
|
rootDir,
|
|
1767
1823
|
publicDir,
|
|
1768
1824
|
cors: corsOptions,
|
|
1769
|
-
streaming,
|
|
1770
|
-
rateLimit: rateLimitOptions,
|
|
1771
|
-
cssPath,
|
|
1772
|
-
managementToken,
|
|
1773
|
-
};
|
|
1825
|
+
streaming,
|
|
1826
|
+
rateLimit: rateLimitOptions,
|
|
1827
|
+
cssPath,
|
|
1828
|
+
managementToken,
|
|
1829
|
+
};
|
|
1774
1830
|
|
|
1775
1831
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
1776
1832
|
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -139,10 +139,23 @@ function generateHydrationScripts(
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// Island 번들 modulepreload (성능 최적화 - prefetch only)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
// Per-island bundles take precedence when available
|
|
143
|
+
const routeIslands = manifest.islands
|
|
144
|
+
? Object.values(manifest.islands).filter((ib) => ib.route === routeId)
|
|
145
|
+
: [];
|
|
146
|
+
|
|
147
|
+
if (routeIslands.length > 0) {
|
|
148
|
+
for (const ib of routeIslands) {
|
|
149
|
+
const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
150
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
// Fallback: route-level bundle (backward compat)
|
|
154
|
+
const bundle = manifest.bundles[routeId];
|
|
155
|
+
if (bundle) {
|
|
156
|
+
const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
157
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
158
|
+
}
|
|
146
159
|
}
|
|
147
160
|
|
|
148
161
|
// Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
|