@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.
@@ -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
+ }
@@ -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
- return errorToResponse(result.error, registry.settings.isDev);
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
- // 4. 캐시 저장 (revalidate 설정이 있는 경우 non-blocking)
1458
- if (cache && ssrResult.ok && renderMode !== "dynamic") {
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
 
@@ -139,10 +139,23 @@ function generateHydrationScripts(
139
139
  }
140
140
 
141
141
  // Island 번들 modulepreload (성능 최적화 - prefetch only)
142
- const bundle = manifest.bundles[routeId];
143
- if (bundle) {
144
- const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
145
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
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 사용)