@mandujs/core 0.54.16 → 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.
- package/package.json +3 -1
- package/src/agent/__tests__/context.test.ts +54 -16
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +440 -8
- package/src/bundler/build.ts +455 -132
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/diagnose/__tests__/checks.test.ts +117 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +43 -6
- package/src/router/client-entry.ts +33 -12
- package/src/router/fs-routes.test.ts +388 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +166 -18
- package/src/router/fs-types.ts +4 -1
- package/src/runtime/__tests__/inline-client-hydration.test.ts +134 -0
- package/src/runtime/__tests__/page-render-response.test.ts +212 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +43 -3
- package/src/runtime/server.ts +42 -5
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
package/src/runtime/handlers.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type PageRegistration,
|
|
24
24
|
} from "./server";
|
|
25
25
|
import { registerManifest } from "./registry";
|
|
26
|
-
import { needsHydration, type RoutesManifest } from "../spec/schema";
|
|
26
|
+
import { needsHydration, type RouteClientBoundary, type RoutesManifest } from "../spec/schema";
|
|
27
27
|
|
|
28
28
|
type RouteModule = Record<string, unknown>;
|
|
29
29
|
|
|
@@ -84,7 +84,7 @@ function createMethodDispatcher(module: RouteModule, routeId: string) {
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export interface RegisterHandlersOptions {
|
|
87
|
+
export interface RegisterHandlersOptions {
|
|
88
88
|
/**
|
|
89
89
|
* Module import function (dev: importFresh, start: standard import).
|
|
90
90
|
* The optional `opts.changedFile` is forwarded into Phase 7.0 B5's
|
|
@@ -92,7 +92,17 @@ export interface RegisterHandlersOptions {
|
|
|
92
92
|
* module's import graph, `importFn` returns the cached bundle in ~0.1 ms
|
|
93
93
|
* instead of re-running Bun.build.
|
|
94
94
|
*/
|
|
95
|
-
importFn: (
|
|
95
|
+
importFn: (
|
|
96
|
+
modulePath: string,
|
|
97
|
+
opts?: {
|
|
98
|
+
changedFile?: string;
|
|
99
|
+
clientBoundaryTransform?: {
|
|
100
|
+
routeId: string;
|
|
101
|
+
hydrate?: string;
|
|
102
|
+
boundaries?: RouteClientBoundary[];
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
) => Promise<unknown>;
|
|
96
106
|
/** Set for tracking already registered layout paths */
|
|
97
107
|
registeredLayouts: Set<string>;
|
|
98
108
|
/** Clear layout cache on reload */
|
|
@@ -115,9 +125,23 @@ export async function registerManifestHandlers(
|
|
|
115
125
|
rootDir: string,
|
|
116
126
|
options: RegisterHandlersOptions
|
|
117
127
|
): Promise<void> {
|
|
118
|
-
const { importFn, registeredLayouts, isReload = false, changedFile } = options;
|
|
119
|
-
const
|
|
120
|
-
changedFile !== undefined ? { changedFile } : undefined;
|
|
128
|
+
const { importFn, registeredLayouts, isReload = false, changedFile } = options;
|
|
129
|
+
const baseImportOpts: { changedFile?: string } | undefined =
|
|
130
|
+
changedFile !== undefined ? { changedFile } : undefined;
|
|
131
|
+
const importOptsForRoute = (route?: RoutesManifest["routes"][number]) => {
|
|
132
|
+
const boundaryTransform = route?.kind === "page" && route.boundaries?.length
|
|
133
|
+
? {
|
|
134
|
+
routeId: route.id,
|
|
135
|
+
hydrate: route.hydration?.priority ?? "visible",
|
|
136
|
+
boundaries: route.boundaries,
|
|
137
|
+
}
|
|
138
|
+
: undefined;
|
|
139
|
+
if (!baseImportOpts && !boundaryTransform) return undefined;
|
|
140
|
+
return {
|
|
141
|
+
...baseImportOpts,
|
|
142
|
+
...(boundaryTransform ? { clientBoundaryTransform: boundaryTransform } : {}),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
121
145
|
|
|
122
146
|
if (isReload) {
|
|
123
147
|
registeredLayouts.clear();
|
|
@@ -134,19 +158,19 @@ export async function registerManifestHandlers(
|
|
|
134
158
|
// runtime dispatcher invokes the default export on each request
|
|
135
159
|
// so HMR reloads pick up edits automatically (same pattern as
|
|
136
160
|
// API routes below).
|
|
137
|
-
if (route.kind === "metadata") {
|
|
138
|
-
const modulePath = path.resolve(rootDir, route.module);
|
|
139
|
-
registerMetadataHandler(route.id, async () => {
|
|
140
|
-
return importFn(modulePath,
|
|
141
|
-
});
|
|
142
|
-
console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
|
|
143
|
-
continue;
|
|
161
|
+
if (route.kind === "metadata") {
|
|
162
|
+
const modulePath = path.resolve(rootDir, route.module);
|
|
163
|
+
registerMetadataHandler(route.id, async () => {
|
|
164
|
+
return importFn(modulePath, importOptsForRoute(route));
|
|
165
|
+
});
|
|
166
|
+
console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
|
|
167
|
+
continue;
|
|
144
168
|
}
|
|
145
169
|
|
|
146
|
-
if (route.kind === "api") {
|
|
147
|
-
const modulePath = path.resolve(rootDir, route.module);
|
|
148
|
-
try {
|
|
149
|
-
const module = (await importFn(modulePath,
|
|
170
|
+
if (route.kind === "api") {
|
|
171
|
+
const modulePath = path.resolve(rootDir, route.module);
|
|
172
|
+
try {
|
|
173
|
+
const module = (await importFn(modulePath, importOptsForRoute(route))) as RouteModule;
|
|
150
174
|
let handler: unknown = module.default ?? module.handler ?? module;
|
|
151
175
|
|
|
152
176
|
// 1) ManduFilling instance
|
|
@@ -193,7 +217,7 @@ export async function registerManifestHandlers(
|
|
|
193
217
|
// Layout modules must export a default component. Runtime
|
|
194
218
|
// validation in `renderToHTML` / page-loader asserts this —
|
|
195
219
|
// so casting the unknown `importFn` result is safe here.
|
|
196
|
-
return importFn(absLayoutPath,
|
|
220
|
+
return importFn(absLayoutPath, baseImportOpts);
|
|
197
221
|
}) as Parameters<typeof registerLayoutLoader>[1]);
|
|
198
222
|
registeredLayouts.add(layoutPath);
|
|
199
223
|
console.log(` 🎨 Layout: ${layoutPath}`);
|
|
@@ -204,7 +228,7 @@ export async function registerManifestHandlers(
|
|
|
204
228
|
// Use PageHandler if slotModule exists (filling.loader support)
|
|
205
229
|
if (route.slotModule) {
|
|
206
230
|
registerPageHandler(route.id, async () => {
|
|
207
|
-
const mod = (await importFn(componentPath,
|
|
231
|
+
const mod = (await importFn(componentPath, importOptsForRoute(route))) as Record<string, unknown>;
|
|
208
232
|
// Normalize the page module shape. Users write pages in two styles:
|
|
209
233
|
// (a) `export default function Page() {…}` + `export const filling = …`
|
|
210
234
|
// (b) `export default { component: …, filling: … }`
|
|
@@ -242,7 +266,7 @@ export async function registerManifestHandlers(
|
|
|
242
266
|
` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
|
|
243
267
|
);
|
|
244
268
|
} else {
|
|
245
|
-
registerPageLoader(route.id, (() => importFn(componentPath,
|
|
269
|
+
registerPageLoader(route.id, (() => importFn(componentPath, importOptsForRoute(route))) as Parameters<typeof registerPageLoader>[1]);
|
|
246
270
|
console.log(
|
|
247
271
|
` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
|
|
248
272
|
);
|
|
@@ -252,7 +276,7 @@ export async function registerManifestHandlers(
|
|
|
252
276
|
|
|
253
277
|
// Phase 6.3: register `app/not-found.tsx` if it exists. Global, one per
|
|
254
278
|
// app — the server falls through to the built-in 404 if unregistered.
|
|
255
|
-
await registerAppNotFound(rootDir, importFn,
|
|
279
|
+
await registerAppNotFound(rootDir, importFn, baseImportOpts);
|
|
256
280
|
}
|
|
257
281
|
|
|
258
282
|
/**
|
|
@@ -260,11 +284,11 @@ export async function registerManifestHandlers(
|
|
|
260
284
|
* project root and register it as the app-level 404 handler. Silent
|
|
261
285
|
* no-op if no file exists — the server's built-in 404 covers that case.
|
|
262
286
|
*/
|
|
263
|
-
async function registerAppNotFound(
|
|
264
|
-
rootDir: string,
|
|
265
|
-
importFn:
|
|
266
|
-
importOpts?: { changedFile?: string },
|
|
267
|
-
): Promise<void> {
|
|
287
|
+
async function registerAppNotFound(
|
|
288
|
+
rootDir: string,
|
|
289
|
+
importFn: RegisterHandlersOptions["importFn"],
|
|
290
|
+
importOpts?: { changedFile?: string },
|
|
291
|
+
): Promise<void> {
|
|
268
292
|
const candidates = [
|
|
269
293
|
"app/not-found.tsx",
|
|
270
294
|
"app/not-found.ts",
|
|
@@ -125,11 +125,14 @@ async function resolveAndWrapInlineClientHydration(
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
if (typeof type === "function" &&
|
|
129
|
-
const rendered = await (
|
|
128
|
+
if (typeof type === "function" && !isClassComponent(type)) {
|
|
129
|
+
const rendered = await renderFunctionComponentForInlineHydration(
|
|
130
|
+
type,
|
|
130
131
|
element.props ?? {},
|
|
131
132
|
);
|
|
132
|
-
|
|
133
|
+
if (rendered.ok) {
|
|
134
|
+
return resolveAndWrapInlineClientHydration(rendered.node, target, counter);
|
|
135
|
+
}
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
const props = element.props;
|
|
@@ -154,6 +157,42 @@ function isAsyncFunctionComponent(type: Function): boolean {
|
|
|
154
157
|
(type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
function isClassComponent(type: Function): boolean {
|
|
161
|
+
return !!type.prototype?.isReactComponent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function renderFunctionComponentForInlineHydration(
|
|
165
|
+
type: Function,
|
|
166
|
+
props: Record<string, unknown>,
|
|
167
|
+
): Promise<{ ok: true; node: React.ReactNode } | { ok: false }> {
|
|
168
|
+
const render = type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>;
|
|
169
|
+
if (isAsyncFunctionComponent(type)) {
|
|
170
|
+
return { ok: true, node: await render(props) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (functionComponentLooksHookDependent(type)) {
|
|
174
|
+
return { ok: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
return { ok: true, node: await render(props) };
|
|
179
|
+
} catch {
|
|
180
|
+
return { ok: false };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function functionComponentLooksHookDependent(type: Function): boolean {
|
|
185
|
+
let source = "";
|
|
186
|
+
try {
|
|
187
|
+
source = Function.prototype.toString.call(type);
|
|
188
|
+
} catch {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return /\bReact\.use[A-Za-z0-9_$]*\s*\(/.test(source) ||
|
|
193
|
+
/\buse[A-Z][A-Za-z0-9_$]*\s*\(/.test(source);
|
|
194
|
+
}
|
|
195
|
+
|
|
157
196
|
function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
|
|
158
197
|
return priority === "immediate" ? "load" : priority;
|
|
159
198
|
}
|
|
@@ -175,6 +214,7 @@ async function renderStreamingPageResponse(
|
|
|
175
214
|
criticalData: options.loaderData as Record<string, unknown> | undefined,
|
|
176
215
|
enableClientRouter: true,
|
|
177
216
|
cssPath: options.cssPath,
|
|
217
|
+
islandPreWrapped: !!options.islandPreWrapped,
|
|
178
218
|
transitions: options.transitions,
|
|
179
219
|
prefetch: options.prefetch,
|
|
180
220
|
spa: options.spa,
|
package/src/runtime/server.ts
CHANGED
|
@@ -1124,16 +1124,13 @@ async function resolveInlineClientHydrationTarget(
|
|
|
1124
1124
|
rootDir: string,
|
|
1125
1125
|
src: string,
|
|
1126
1126
|
): Promise<InlineClientHydrationTarget | undefined> {
|
|
1127
|
-
if (!route.clientModule || !
|
|
1127
|
+
if (!route.clientModule || !src) {
|
|
1128
1128
|
return undefined;
|
|
1129
1129
|
}
|
|
1130
1130
|
|
|
1131
1131
|
try {
|
|
1132
1132
|
const module = await import(path.join(rootDir, route.clientModule));
|
|
1133
|
-
const
|
|
1134
|
-
const component = exportName === "default"
|
|
1135
|
-
? module.default
|
|
1136
|
-
: module[exportName] ?? module.default;
|
|
1133
|
+
const component = resolveInlineClientHydrationComponent(module, route);
|
|
1137
1134
|
|
|
1138
1135
|
if (!component) return undefined;
|
|
1139
1136
|
|
|
@@ -1152,6 +1149,46 @@ async function resolveInlineClientHydrationTarget(
|
|
|
1152
1149
|
}
|
|
1153
1150
|
}
|
|
1154
1151
|
|
|
1152
|
+
function resolveInlineClientHydrationComponent(
|
|
1153
|
+
module: Record<string, unknown>,
|
|
1154
|
+
route: { id: string; clientModule?: string; clientExportName?: string },
|
|
1155
|
+
): unknown {
|
|
1156
|
+
if (module.default) return module.default;
|
|
1157
|
+
|
|
1158
|
+
const candidates = [
|
|
1159
|
+
route.clientExportName && route.clientExportName !== "default" ? route.clientExportName : undefined,
|
|
1160
|
+
inferInlineClientExportNameFromPath(route.clientModule),
|
|
1161
|
+
inferInlineClientExportNameFromRouteId(route.id),
|
|
1162
|
+
].filter((candidate, index, values): candidate is string =>
|
|
1163
|
+
!!candidate && values.indexOf(candidate) === index
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
for (const candidate of candidates) {
|
|
1167
|
+
if (module[candidate]) return module[candidate];
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const runtimeExports = Object.keys(module).filter((name) => name !== "__esModule");
|
|
1171
|
+
return runtimeExports.length === 1 ? module[runtimeExports[0]] : undefined;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function inferInlineClientExportNameFromPath(clientModulePath: string | undefined): string | undefined {
|
|
1175
|
+
if (!clientModulePath) return undefined;
|
|
1176
|
+
const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
|
|
1177
|
+
const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
|
|
1178
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix)
|
|
1179
|
+
? withoutClientSuffix
|
|
1180
|
+
: undefined;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function inferInlineClientExportNameFromRouteId(routeId: string): string | undefined {
|
|
1184
|
+
const pascal = routeId
|
|
1185
|
+
.split(/[^A-Za-z0-9]+/)
|
|
1186
|
+
.filter(Boolean)
|
|
1187
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1188
|
+
.join("");
|
|
1189
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : undefined;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1155
1192
|
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
1156
1193
|
|
|
1157
1194
|
// ========== Request Handler ==========
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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 =
|
|
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/
|
|
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
|
-
/**
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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 =
|
|
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
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
|
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 =
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
|
package/src/spec/schema.ts
CHANGED
|
@@ -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
|
-
// ==========
|
|
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(),
|