@mandujs/core 0.54.17 → 0.54.18

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.
Files changed (35) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +54 -16
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  6. package/src/bundler/__tests__/build-runner.ts +130 -17
  7. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  8. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  9. package/src/bundler/build.test.ts +440 -8
  10. package/src/bundler/build.ts +455 -132
  11. package/src/bundler/client-boundary-transform.ts +977 -0
  12. package/src/bundler/dev.ts +39 -112
  13. package/src/bundler/fast-refresh-preamble.ts +47 -0
  14. package/src/bundler/index.ts +3 -2
  15. package/src/bundler/manifest-schema.ts +10 -0
  16. package/src/bundler/types.ts +20 -2
  17. package/src/diagnose/__tests__/checks.test.ts +117 -17
  18. package/src/diagnose/checks.ts +184 -3
  19. package/src/diagnose/run.ts +10 -8
  20. package/src/generator/templates.test.ts +48 -5
  21. package/src/generator/templates.ts +10 -1
  22. package/src/internal/client-boundary.ts +266 -0
  23. package/src/internal/index.ts +2 -1
  24. package/src/router/client-entry.test.ts +43 -6
  25. package/src/router/client-entry.ts +33 -12
  26. package/src/router/fs-routes.test.ts +388 -1
  27. package/src/router/fs-routes.ts +16 -3
  28. package/src/router/fs-scanner.ts +166 -18
  29. package/src/router/fs-types.ts +4 -1
  30. package/src/runtime/__tests__/page-render-response.test.ts +212 -0
  31. package/src/runtime/handlers.ts +50 -26
  32. package/src/runtime/page-render-response.ts +1 -0
  33. package/src/runtime/ssr.ts +16 -5
  34. package/src/runtime/streaming-ssr.ts +119 -76
  35. package/src/spec/schema.ts +31 -5
@@ -8,10 +8,11 @@ import type { HydrationConfig, HydrationPriority } from "../spec/schema";
8
8
  import { PORTS, TIMEOUTS } from "../constants";
9
9
  import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
10
10
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
- import { generateFastRefreshPreamble } from "../bundler/dev";
11
+ import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
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
+ import { maybeInjectDevOverlay } from "../dev-error-overlay";
15
+ import { renderWithManduClientBoundaryManifest } from "../internal/client-boundary";
15
16
 
16
17
  /**
17
18
  * Issue #192 — `@view-transition` at-rule block.
@@ -275,8 +276,16 @@ function generateHydrationScripts(
275
276
  scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
276
277
  }
277
278
  }
278
-
279
- // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
279
+
280
+ if (manifest.boundaries) {
281
+ for (const boundary of Object.values(manifest.boundaries)) {
282
+ if (boundary.route !== routeId) continue;
283
+ const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
284
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
285
+ }
286
+ }
287
+
288
+ // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
280
289
  if (manifest.shared.runtime) {
281
290
  scripts.push(`<script type="module" src="${escapeHtmlAttr(manifest.shared.runtime)}"></script>`);
282
291
  }
@@ -703,7 +712,9 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
703
712
  } catch { /* client 모듈 로드 실패 시 무시 */ }
704
713
 
705
714
  const renderToString = getRenderToString();
706
- let content = renderToString(element);
715
+ let content = renderWithManduClientBoundaryManifest(routeId, bundleManifest, () =>
716
+ renderToString(element),
717
+ );
707
718
 
708
719
  // 렌더링 중 수집된 head 태그
709
720
  collectedHeadTags = headGet?.() ?? "";
@@ -23,9 +23,13 @@ import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript, escapeJsStri
23
23
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
24
24
  import { getRenderToString } from "./react-renderer";
25
25
  import { mark, measure } from "../perf";
26
- import { generateFastRefreshPreamble } from "../bundler/dev";
27
- import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
28
- import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
26
+ import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
27
+ import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
28
+ import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
29
+ import {
30
+ createManduClientBoundaryRenderScope,
31
+ renderWithManduClientBoundaryManifest,
32
+ } from "../internal/client-boundary";
29
33
 
30
34
  /**
31
35
  * Issue #192 — `@view-transition` at-rule, mirror of the constant in
@@ -110,10 +114,12 @@ export interface StreamingSSROptions {
110
114
  // Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨
111
115
  /** Hydration 설정 */
112
116
  hydration?: HydrationConfig;
113
- /** 번들 매니페스트 */
114
- bundleManifest?: BundleManifest;
115
- /** 추가 head 태그 (SEO metadata와 병합됨) */
116
- headTags?: string;
117
+ /** 번들 매니페스트 */
118
+ bundleManifest?: BundleManifest;
119
+ /** React element already contains its own island wrapper. */
120
+ islandPreWrapped?: boolean;
121
+ /** 추가 head 태그 (SEO metadata와 병합됨) */
122
+ headTags?: string;
117
123
  /**
118
124
  * SEO 메타데이터 (Layout 체인 또는 단일 객체)
119
125
  * - 배열: [rootLayout, ...nestedLayouts, page] 순서로 병합
@@ -550,9 +556,10 @@ function generateHTMLShell(options: StreamingSSROptions): string {
550
556
  isDev = false,
551
557
  transitions = true,
552
558
  prefetch = true,
553
- spa = true,
554
- layoutChain,
555
- } = options;
559
+ spa = true,
560
+ layoutChain,
561
+ islandPreWrapped = false,
562
+ } = options;
556
563
 
557
564
  // Issue #233 — layout-key for SPA cross-layout detection. Mirror of the
558
565
  // block in `ssr.ts::renderToHTML`; see that call-site for the full
@@ -627,7 +634,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
627
634
  const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
628
635
  const priority = hydration.priority || "visible";
629
636
  const hydrate = priorityToHydrateStrategy(priority);
630
- if (hasRouteBundle) {
637
+ if (hasRouteBundle && !islandPreWrapped) {
631
638
  islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" data-hydrate="${escapeHtmlAttr(hydrate)}" style="display:contents">`;
632
639
  }
633
640
  }
@@ -695,10 +702,11 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
695
702
  bundleManifest,
696
703
  isDev = false,
697
704
  hmrPort,
698
- enableClientRouter = false,
699
- hydration,
700
- devtools,
701
- } = options;
705
+ enableClientRouter = false,
706
+ hydration,
707
+ devtools,
708
+ islandPreWrapped = false,
709
+ } = options;
702
710
 
703
711
  const scripts: string[] = [];
704
712
 
@@ -755,11 +763,21 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
755
763
  scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundleManifest.shared.runtime)}">`);
756
764
  }
757
765
 
758
- // 6. Island modulepreload
759
- const bundle = bundleManifest.bundles[routeId];
760
- if (bundle) {
761
- const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
762
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
766
+ // 6. Island modulepreload
767
+ const routeIslands = bundleManifest.islands
768
+ ? Object.values(bundleManifest.islands).filter((island) => island.route === routeId)
769
+ : [];
770
+ if (routeIslands.length > 0) {
771
+ for (const island of routeIslands) {
772
+ const cacheBust = `${island.js}${island.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
773
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
774
+ }
775
+ } else {
776
+ const bundle = bundleManifest.bundles[routeId];
777
+ if (bundle) {
778
+ const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
779
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
780
+ }
763
781
  }
764
782
  if (bundleManifest.partials) {
765
783
  for (const partial of Object.values(bundleManifest.partials)) {
@@ -767,10 +785,18 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
767
785
  scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
768
786
  }
769
787
  }
770
-
771
- // 7. Runtime 로드
772
- if (bundleManifest.shared.runtime) {
773
- scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.runtime)}"></script>`);
788
+
789
+ if (bundleManifest.boundaries) {
790
+ for (const boundary of Object.values(bundleManifest.boundaries)) {
791
+ if (boundary.route !== routeId) continue;
792
+ const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
793
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
794
+ }
795
+ }
796
+
797
+ // 7. Runtime 로드
798
+ if (bundleManifest.shared.runtime) {
799
+ scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.runtime)}"></script>`);
774
800
  }
775
801
 
776
802
  // 7.5 React internals shim (must run before react-dom/client runs)
@@ -798,9 +824,9 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
798
824
  if (isDev && shouldInjectDevtoolsStreaming(devtools, bundleManifest)) {
799
825
  scripts.push(generateStreamingDevtoolsScript(bundleManifest));
800
826
  }
801
-
802
- // Island wrapper 닫기 (hydration이 필요한 경우)
803
- const islandCloseTag = needsHydration && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
827
+
828
+ // Island wrapper 닫기 (hydration이 필요한 경우)
829
+ const islandCloseTag = needsHydration && !islandPreWrapped && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
804
830
 
805
831
  return `${islandCloseTag}</div>
806
832
  ${scripts.join("\n ")}`;
@@ -1028,9 +1054,13 @@ export async function renderToStream(
1028
1054
  warnStreamingCaveats(isDev);
1029
1055
  streamingWarnings.markWarned();
1030
1056
  }
1031
-
1032
- const encoder = new TextEncoder();
1033
- const collectedHeadTags = collectStreamingHeadTags(element);
1057
+
1058
+ const encoder = new TextEncoder();
1059
+ const collectedHeadTags = renderWithManduClientBoundaryManifest(
1060
+ options.routeId,
1061
+ options.bundleManifest,
1062
+ () => collectStreamingHeadTags(element),
1063
+ );
1034
1064
  const resolvedOptions = collectedHeadTags
1035
1065
  ? { ...options, headTags: [options.headTags, collectedHeadTags].filter(Boolean).join("\n") }
1036
1066
  : options;
@@ -1072,44 +1102,53 @@ export async function renderToStream(
1072
1102
  // try/catch safely returns an empty string in that case, and any
1073
1103
  // `useHead`-pushed tags from async components are instead picked
1074
1104
  // up by `buildHtmlTail` on the way out. No additional wiring is
1075
- // needed on this code path.
1076
- // 실패 시 throw → renderStreamingResponse에서 500 처리
1077
- const renderToReadableStream = getRenderToReadableStream();
1078
- const reactStream = await renderToReadableStream(element, {
1079
- onError: (error: Error) => {
1080
- if (timedOut) return;
1081
-
1082
- metrics.hasError = true;
1083
- const streamingError: StreamingError = {
1084
- error,
1085
- isShellError: !shellSent,
1086
- recoverable: shellSent,
1087
- timestamp: Date.now(),
1088
- };
1089
-
1090
- console.error("[Mandu Streaming] React render error:", error);
1091
-
1092
- if (!shellSent) {
1093
- // Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨)
1094
- onShellError?.(streamingError);
1095
- } else {
1096
- // Shell 에러 - 스트림에 에러 스크립트 삽입됨
1097
- onStreamError?.(streamingError);
1098
- }
1099
-
1100
- onError?.(error);
1101
- },
1102
- });
1103
-
1104
- // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
1105
- reactStream.allReady.then(() => {
1106
- metrics.allReadyTime = Date.now() - metrics.startTime;
1107
- if (isDev) {
1108
- console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`);
1109
- }
1110
- }).catch(() => {
1111
- // 에러는 onError에서 이미 처리됨
1112
- });
1105
+ // needed on this code path.
1106
+ // 실패 시 throw → renderStreamingResponse에서 500 처리
1107
+ const renderToReadableStream = getRenderToReadableStream();
1108
+ const renderBoundaryScope = createManduClientBoundaryRenderScope(
1109
+ routeId,
1110
+ resolvedOptions.bundleManifest,
1111
+ );
1112
+ const scopedElement = renderBoundaryScope.wrapElement(element);
1113
+ const reactStream = await renderBoundaryScope(() =>
1114
+ renderToReadableStream(scopedElement, {
1115
+ onError: (error: Error) => {
1116
+ if (timedOut) return;
1117
+
1118
+ metrics.hasError = true;
1119
+ const streamingError: StreamingError = {
1120
+ error,
1121
+ isShellError: !shellSent,
1122
+ recoverable: shellSent,
1123
+ timestamp: Date.now(),
1124
+ };
1125
+
1126
+ console.error("[Mandu Streaming] React render error:", error);
1127
+
1128
+ if (!shellSent) {
1129
+ // Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨)
1130
+ onShellError?.(streamingError);
1131
+ } else {
1132
+ // Shell 후 에러 - 스트림에 에러 스크립트 삽입됨
1133
+ onStreamError?.(streamingError);
1134
+ }
1135
+
1136
+ onError?.(error);
1137
+ },
1138
+ }),
1139
+ );
1140
+
1141
+ // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
1142
+ renderBoundaryScope(() => {
1143
+ reactStream.allReady.then(() => {
1144
+ metrics.allReadyTime = Date.now() - metrics.startTime;
1145
+ if (isDev) {
1146
+ console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`);
1147
+ }
1148
+ }).catch(() => {
1149
+ // 에러는 onError에서 이미 처리됨
1150
+ });
1151
+ });
1113
1152
 
1114
1153
  // Custom stream으로 래핑 (Shell + React Content + Tail)
1115
1154
  let tailSent = false;
@@ -1118,10 +1157,12 @@ export async function renderToStream(
1118
1157
  ? metrics.startTime + streamTimeout
1119
1158
  : null;
1120
1159
 
1121
- async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
1122
- if (!deadline) {
1123
- return reader.read() as Promise<ReadableStreamReadResult<Uint8Array>>;
1124
- }
1160
+ async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
1161
+ if (!deadline) {
1162
+ return renderBoundaryScope(() =>
1163
+ reader.read() as Promise<ReadableStreamReadResult<Uint8Array>>
1164
+ );
1165
+ }
1125
1166
 
1126
1167
  const remaining = deadline - Date.now();
1127
1168
  if (remaining <= 0) {
@@ -1133,10 +1174,12 @@ export async function renderToStream(
1133
1174
  timeoutId = setTimeout(() => resolve({ kind: "timeout" }), remaining);
1134
1175
  });
1135
1176
 
1136
- const readPromise = reader
1137
- .read()
1138
- .then((result) => ({ kind: "read" as const, result: result as ReadableStreamReadResult<Uint8Array> }))
1139
- .catch((error: unknown) => ({ kind: "error" as const, error }));
1177
+ const readPromise = renderBoundaryScope(() =>
1178
+ reader
1179
+ .read()
1180
+ .then((result) => ({ kind: "read" as const, result: result as ReadableStreamReadResult<Uint8Array> }))
1181
+ .catch((error: unknown) => ({ kind: "error" as const, error }))
1182
+ );
1140
1183
 
1141
1184
  const result = await Promise.race([readPromise, timeoutPromise]);
1142
1185
 
@@ -8,7 +8,7 @@ export type SpecHydrationStrategy = z.infer<typeof SpecHydrationStrategy>;
8
8
  export const HydrationPriority = z.enum(["immediate", "visible", "idle", "interaction"]);
9
9
  export type HydrationPriority = z.infer<typeof HydrationPriority>;
10
10
 
11
- export const HydrationConfig = z.object({
11
+ export const HydrationConfig = z.object({
12
12
  /**
13
13
  * Hydration 전략
14
14
  * - none: 순수 Static HTML (JS 없음)
@@ -32,10 +32,35 @@ export const HydrationConfig = z.object({
32
32
  */
33
33
  preload: z.boolean().default(false),
34
34
  });
35
-
36
- export type HydrationConfig = z.infer<typeof HydrationConfig>;
37
-
38
- // ========== Loader 설정 ==========
35
+
36
+ export type HydrationConfig = z.infer<typeof HydrationConfig>;
37
+
38
+ // ========== Client Boundary Metadata ==========
39
+
40
+ export const RouteClientBoundarySource = z.object({
41
+ file: z.string().min(1),
42
+ line: z.number().int().positive(),
43
+ column: z.number().int().positive(),
44
+ });
45
+ export type RouteClientBoundarySource = z.infer<typeof RouteClientBoundarySource>;
46
+
47
+ export const RouteClientBoundary = z.object({
48
+ id: z.string().min(1),
49
+ routeId: z.string().min(1),
50
+ module: z.string().min(1),
51
+ importSpecifier: z.string().min(1).optional(),
52
+ exportName: z.string().min(1),
53
+ localName: z.string().min(1),
54
+ hydrate: z.string().min(1).default("visible"),
55
+ ordinal: z.number().int().nonnegative(),
56
+ propsSource: z.enum(["inline", "route-data", "data-props", "none", "unknown"]).default("inline"),
57
+ propsKeys: z.array(z.string()).optional(),
58
+ hasSpreadProps: z.boolean().optional(),
59
+ source: RouteClientBoundarySource,
60
+ });
61
+ export type RouteClientBoundary = z.infer<typeof RouteClientBoundary>;
62
+
63
+ // ========== Loader 설정 ==========
39
64
 
40
65
  export const LoaderConfig = z.object({
41
66
  /**
@@ -75,6 +100,7 @@ const RouteSpecBase = {
75
100
  slotModule: z.string().optional(),
76
101
  clientModule: z.string().optional(),
77
102
  clientExportName: z.string().optional(),
103
+ boundaries: z.array(RouteClientBoundary).optional(),
78
104
  contractModule: z.string().optional(),
79
105
  hydration: HydrationConfig.optional(),
80
106
  loader: LoaderConfig.optional(),