@mandujs/core 0.54.2 → 0.54.3
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 +4 -2
- package/scripts/postinstall-lock.ts +153 -0
- package/src/bundler/__tests__/cold-start.test.ts +35 -7
- package/src/bundler/analyzer.ts +15 -7
- package/src/bundler/build.test.ts +13 -6
- package/src/bundler/build.ts +403 -163
- package/src/bundler/manifest-schema.ts +21 -14
- package/src/bundler/types.ts +31 -14
- package/src/client/island.ts +79 -29
- package/src/guard/config-guard.ts +13 -7
- package/src/guard/fs-routes-policy.ts +51 -0
- package/src/guard/index.ts +11 -6
- package/src/runtime/__tests__/page-render-response.test.ts +49 -0
- package/src/runtime/page-render-response.ts +1 -5
- package/src/runtime/ssr.ts +39 -30
- package/src/runtime/streaming-ssr.ts +22 -13
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
*
|
|
29
29
|
* URL safety model (applies to `shared.runtime`, `shared.vendor`,
|
|
30
30
|
* `shared.router`, `shared.fastRefresh.glue`, `shared.fastRefresh.runtime`,
|
|
31
|
-
* `bundles[].js`, `bundles[].css`, `islands[].js`, `
|
|
31
|
+
* `bundles[].js`, `bundles[].css`, `islands[].js`, `partials[].js`,
|
|
32
|
+
* `importMap.imports[*]`):
|
|
32
33
|
*
|
|
33
34
|
* ALLOW: absolute paths rooted at `/.mandu/client/` ending in `.js` or `.css`.
|
|
34
35
|
* The bundler itself only ever emits this shape.
|
|
@@ -161,15 +162,20 @@ const BundleEntrySchema = z.object({
|
|
|
161
162
|
priority: PrioritySchema,
|
|
162
163
|
});
|
|
163
164
|
|
|
164
|
-
const IslandEntrySchema = z.object({
|
|
165
|
-
js: safeManduUrl("islands[].js"),
|
|
166
|
-
route: z.string().min(1),
|
|
167
|
-
priority: PrioritySchema,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
165
|
+
const IslandEntrySchema = z.object({
|
|
166
|
+
js: safeManduUrl("islands[].js"),
|
|
167
|
+
route: z.string().min(1),
|
|
168
|
+
priority: PrioritySchema,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const PartialEntrySchema = z.object({
|
|
172
|
+
js: safeManduUrl("partials[].js"),
|
|
173
|
+
priority: PrioritySchema,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const FastRefreshSchema = z.object({
|
|
177
|
+
runtime: safeManduUrl("shared.fastRefresh.runtime"),
|
|
178
|
+
glue: safeManduUrl("shared.fastRefresh.glue"),
|
|
173
179
|
});
|
|
174
180
|
|
|
175
181
|
const SharedSchema = z.object({
|
|
@@ -219,10 +225,11 @@ export const BundleManifestSchema = z
|
|
|
219
225
|
.object({
|
|
220
226
|
version: z.number().int().min(1),
|
|
221
227
|
buildTime: z.string().min(1),
|
|
222
|
-
env: z.enum(["development", "production"]),
|
|
223
|
-
bundles: z.record(z.string(), BundleEntrySchema),
|
|
224
|
-
islands: z.record(z.string(), IslandEntrySchema).optional(),
|
|
225
|
-
|
|
228
|
+
env: z.enum(["development", "production"]),
|
|
229
|
+
bundles: z.record(z.string(), BundleEntrySchema),
|
|
230
|
+
islands: z.record(z.string(), IslandEntrySchema).optional(),
|
|
231
|
+
partials: z.record(z.string(), PartialEntrySchema).optional(),
|
|
232
|
+
shared: SharedSchema,
|
|
226
233
|
importMap: ImportMapSchema.optional(),
|
|
227
234
|
})
|
|
228
235
|
.strict();
|
package/src/bundler/types.ts
CHANGED
|
@@ -55,18 +55,28 @@ export interface BundleManifest {
|
|
|
55
55
|
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
56
56
|
}
|
|
57
57
|
>;
|
|
58
|
-
/** Per-island bundles (code splitting: each island file gets its own JS bundle) */
|
|
59
|
-
islands?: Record<
|
|
60
|
-
string,
|
|
61
|
-
{
|
|
62
|
-
/** JavaScript bundle path */
|
|
58
|
+
/** Per-island bundles (code splitting: each island file gets its own JS bundle) */
|
|
59
|
+
islands?: Record<
|
|
60
|
+
string,
|
|
61
|
+
{
|
|
62
|
+
/** JavaScript bundle path */
|
|
63
63
|
js: string;
|
|
64
64
|
/** Route that owns this island */
|
|
65
65
|
route: string;
|
|
66
66
|
/** Hydration priority */
|
|
67
|
-
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
68
|
-
}
|
|
69
|
-
>;
|
|
67
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
68
|
+
}
|
|
69
|
+
>;
|
|
70
|
+
/** Inline partial bundles (from *.partial.tsx / *.partial.ts files) */
|
|
71
|
+
partials?: Record<
|
|
72
|
+
string,
|
|
73
|
+
{
|
|
74
|
+
/** JavaScript bundle path */
|
|
75
|
+
js: string;
|
|
76
|
+
/** Hydration priority */
|
|
77
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
78
|
+
}
|
|
79
|
+
>;
|
|
70
80
|
/** 공유 청크 */
|
|
71
81
|
shared: {
|
|
72
82
|
/** Hydration 런타임 */
|
|
@@ -113,12 +123,19 @@ export interface BundleStats {
|
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
/** Per-island code splitting entry (used by scanIslandFiles) */
|
|
116
|
-
export interface IslandFileEntry {
|
|
117
|
-
name: string;
|
|
118
|
-
filePath: string;
|
|
119
|
-
routeId: string;
|
|
120
|
-
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
121
|
-
}
|
|
126
|
+
export interface IslandFileEntry {
|
|
127
|
+
name: string;
|
|
128
|
+
filePath: string;
|
|
129
|
+
routeId: string;
|
|
130
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Inline partial bundle entry (used by scanPartialFiles) */
|
|
134
|
+
export interface PartialFileEntry {
|
|
135
|
+
name: string;
|
|
136
|
+
filePath: string;
|
|
137
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
138
|
+
}
|
|
122
139
|
|
|
123
140
|
/**
|
|
124
141
|
* 번들러 옵션
|
package/src/client/island.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* Hydration을 위한 클라이언트 사이드 컴포넌트 정의
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { ReactNode } from "react";
|
|
7
|
-
import {
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { serializeProps } from "./serialize";
|
|
8
|
+
import { getServerData as getGlobalServerData } from "./window-state";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Island 정의 타입
|
|
@@ -335,26 +336,45 @@ export interface PartialConfig {
|
|
|
335
336
|
/**
|
|
336
337
|
* Partial Island 정의 타입
|
|
337
338
|
*/
|
|
338
|
-
export interface PartialDefinition<TProps> {
|
|
339
|
-
/** Partial
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
export interface PartialDefinition<TProps> {
|
|
340
|
+
/** Partial 고유 ID. 기본값은 component displayName/name 입니다. */
|
|
341
|
+
id?: string;
|
|
342
|
+
/** Partial 컴포넌트 */
|
|
343
|
+
component: React.ComponentType<TProps>;
|
|
344
|
+
/** 초기 props (SSR에서 전달) */
|
|
345
|
+
initialProps?: TProps;
|
|
346
|
+
/** 하이드레이션 우선순위 */
|
|
347
|
+
priority?: "immediate" | "visible" | "idle" | "interaction";
|
|
348
|
+
/** 명시적 번들 URL. 기본값은 `/.mandu/client/{id}.partial.js` 입니다. */
|
|
349
|
+
src?: string;
|
|
350
|
+
/** 에러 시 표시할 UI */
|
|
351
|
+
errorBoundary?: (error: Error, reset: () => void) => ReactNode;
|
|
352
|
+
/** 로딩 중 표시할 UI */
|
|
353
|
+
loading?: () => ReactNode;
|
|
354
|
+
}
|
|
346
355
|
|
|
347
356
|
/**
|
|
348
357
|
* 컴파일된 Partial
|
|
349
358
|
*/
|
|
350
|
-
export interface CompiledPartial<TProps> {
|
|
359
|
+
export interface CompiledPartial<TProps> {
|
|
351
360
|
/** Partial 정의 */
|
|
352
361
|
definition: PartialDefinition<TProps>;
|
|
353
362
|
/** Mandu Partial 마커 */
|
|
354
363
|
__mandu_partial: true;
|
|
355
364
|
/** Partial ID */
|
|
356
|
-
__mandu_partial_id?: string;
|
|
357
|
-
}
|
|
365
|
+
__mandu_partial_id?: string;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function normalizePartialId(id: string): string {
|
|
369
|
+
return id
|
|
370
|
+
.trim()
|
|
371
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
372
|
+
.replace(/^-+|-+$/g, "") || "partial";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function priorityToHydrate(priority: NonNullable<PartialDefinition<unknown>["priority"]>): string {
|
|
376
|
+
return priority === "immediate" ? "load" : priority;
|
|
377
|
+
}
|
|
358
378
|
|
|
359
379
|
/**
|
|
360
380
|
* Partial Island 생성
|
|
@@ -379,26 +399,56 @@ export interface CompiledPartial<TProps> {
|
|
|
379
399
|
* }
|
|
380
400
|
* ```
|
|
381
401
|
*/
|
|
382
|
-
export function partial<TProps extends Record<string, unknown>>(
|
|
383
|
-
definition: PartialDefinition<TProps>
|
|
384
|
-
): CompiledPartial<TProps> & {
|
|
385
|
-
Render: React.ComponentType<TProps>;
|
|
386
|
-
} {
|
|
402
|
+
export function partial<TProps extends Record<string, unknown>>(
|
|
403
|
+
definition: PartialDefinition<TProps>
|
|
404
|
+
): CompiledPartial<TProps> & {
|
|
405
|
+
Render: React.ComponentType<TProps>;
|
|
406
|
+
} {
|
|
387
407
|
if (!definition.component) {
|
|
388
408
|
throw new Error("[Mandu Partial] component is required");
|
|
389
409
|
}
|
|
390
410
|
|
|
391
|
-
const
|
|
392
|
-
definition
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
411
|
+
const partialId = normalizePartialId(
|
|
412
|
+
definition.id ||
|
|
413
|
+
definition.component.displayName ||
|
|
414
|
+
definition.component.name ||
|
|
415
|
+
"partial",
|
|
416
|
+
);
|
|
417
|
+
const normalizedDefinition: PartialDefinition<TProps> = {
|
|
418
|
+
...definition,
|
|
419
|
+
id: partialId,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const compiled: CompiledPartial<TProps> = {
|
|
423
|
+
definition: normalizedDefinition,
|
|
424
|
+
__mandu_partial: true,
|
|
425
|
+
__mandu_partial_id: partialId,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Render 컴포넌트 생성
|
|
429
|
+
const React = require("react");
|
|
430
|
+
|
|
431
|
+
const RenderComponent: React.FC<TProps> = (props) => {
|
|
432
|
+
const renderProps = Object.keys(props).length > 0
|
|
433
|
+
? props
|
|
434
|
+
: (normalizedDefinition.initialProps ?? props);
|
|
435
|
+
const priority = normalizedDefinition.priority ?? "visible";
|
|
436
|
+
const bundleSrc = normalizedDefinition.src ?? `/.mandu/client/${partialId}.partial.js`;
|
|
437
|
+
|
|
438
|
+
return React.createElement(
|
|
439
|
+
"div",
|
|
440
|
+
{
|
|
441
|
+
"data-mandu-island": partialId,
|
|
442
|
+
"data-mandu-partial": partialId,
|
|
443
|
+
"data-mandu-src": bundleSrc,
|
|
444
|
+
"data-mandu-priority": priority,
|
|
445
|
+
"data-hydrate": priorityToHydrate(priority),
|
|
446
|
+
"data-props": serializeProps(renderProps),
|
|
447
|
+
style: { display: "contents" },
|
|
448
|
+
},
|
|
449
|
+
React.createElement(normalizedDefinition.component, renderProps),
|
|
450
|
+
);
|
|
451
|
+
};
|
|
402
452
|
|
|
403
453
|
return Object.assign(compiled, { Render: RenderComponent });
|
|
404
454
|
}
|
|
@@ -187,13 +187,19 @@ export function formatConfigGuardResult(result: ConfigGuardResult): string {
|
|
|
187
187
|
} else if (!result.lockfileExists) {
|
|
188
188
|
lines.push("💡 Lockfile 없음");
|
|
189
189
|
lines.push(" 'mandu lock'으로 생성 권장");
|
|
190
|
-
} else {
|
|
191
|
-
lines.push("❌ 설정 무결성 검증 실패");
|
|
192
|
-
|
|
193
|
-
for (const error of result.errors) {
|
|
194
|
-
lines.push(` 🔴 ${error.message}`);
|
|
195
|
-
}
|
|
196
|
-
|
|
190
|
+
} else {
|
|
191
|
+
lines.push("❌ 설정 무결성 검증 실패");
|
|
192
|
+
|
|
193
|
+
for (const error of result.errors) {
|
|
194
|
+
lines.push(` 🔴 ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push(" 다음 단계:");
|
|
199
|
+
lines.push(" ↳ 변경 확인: mandu lock --diff");
|
|
200
|
+
lines.push(" ↳ 의도한 변경이면: mandu lock");
|
|
201
|
+
lines.push(" ↳ 패키지 업데이트 직후라면 새 Mandu 기본값으로 lockfile을 갱신하세요.");
|
|
202
|
+
}
|
|
197
203
|
|
|
198
204
|
if (result.warnings.length > 0 && result.lockfileExists) {
|
|
199
205
|
lines.push("");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { FSRoutesGuardConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_FS_ROUTES_GUARD_POLICY: FSRoutesGuardConfig = {
|
|
4
|
+
noPageToPage: true,
|
|
5
|
+
pageCanImport: [
|
|
6
|
+
"client/pages",
|
|
7
|
+
"client/widgets",
|
|
8
|
+
"client/features",
|
|
9
|
+
"client/entities",
|
|
10
|
+
"client/shared",
|
|
11
|
+
"shared/contracts",
|
|
12
|
+
"shared/types",
|
|
13
|
+
"shared/utils/client",
|
|
14
|
+
],
|
|
15
|
+
layoutCanImport: [
|
|
16
|
+
"client/app",
|
|
17
|
+
"client/widgets",
|
|
18
|
+
"client/shared",
|
|
19
|
+
"shared/contracts",
|
|
20
|
+
"shared/types",
|
|
21
|
+
"shared/utils/client",
|
|
22
|
+
],
|
|
23
|
+
routeCanImport: [
|
|
24
|
+
"server/api",
|
|
25
|
+
"server/application",
|
|
26
|
+
"server/domain",
|
|
27
|
+
"server/infra",
|
|
28
|
+
"server/core",
|
|
29
|
+
"shared/contracts",
|
|
30
|
+
"shared/schema",
|
|
31
|
+
"shared/types",
|
|
32
|
+
"shared/utils/client",
|
|
33
|
+
"shared/utils/server",
|
|
34
|
+
"shared/env",
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function clonePolicy(policy: FSRoutesGuardConfig): FSRoutesGuardConfig {
|
|
39
|
+
return {
|
|
40
|
+
noPageToPage: policy.noPageToPage,
|
|
41
|
+
pageCanImport: policy.pageCanImport ? [...policy.pageCanImport] : undefined,
|
|
42
|
+
layoutCanImport: policy.layoutCanImport ? [...policy.layoutCanImport] : undefined,
|
|
43
|
+
routeCanImport: policy.routeCanImport ? [...policy.routeCanImport] : undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getDefaultFsRoutesGuardPolicy(
|
|
48
|
+
enabled: boolean
|
|
49
|
+
): FSRoutesGuardConfig | undefined {
|
|
50
|
+
return enabled ? clonePolicy(DEFAULT_FS_ROUTES_GUARD_POLICY) : undefined;
|
|
51
|
+
}
|
package/src/guard/index.ts
CHANGED
|
@@ -120,12 +120,17 @@ export {
|
|
|
120
120
|
// Architecture Guard - Watcher
|
|
121
121
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
122
122
|
|
|
123
|
-
export {
|
|
124
|
-
createGuardWatcher,
|
|
125
|
-
checkFile,
|
|
126
|
-
checkDirectory,
|
|
127
|
-
clearAnalysisCache,
|
|
128
|
-
} from "./watcher";
|
|
123
|
+
export {
|
|
124
|
+
createGuardWatcher,
|
|
125
|
+
checkFile,
|
|
126
|
+
checkDirectory,
|
|
127
|
+
clearAnalysisCache,
|
|
128
|
+
} from "./watcher";
|
|
129
|
+
|
|
130
|
+
export {
|
|
131
|
+
DEFAULT_FS_ROUTES_GUARD_POLICY,
|
|
132
|
+
getDefaultFsRoutesGuardPolicy,
|
|
133
|
+
} from "./fs-routes-policy";
|
|
129
134
|
|
|
130
135
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
131
136
|
// Architecture Guard - Presets
|
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { renderPageResponse } from "../page-render-response";
|
|
4
|
+
import type { BundleManifest } from "../../bundler/types";
|
|
5
|
+
|
|
6
|
+
const HYDRATED_MANIFEST: BundleManifest = {
|
|
7
|
+
version: 1,
|
|
8
|
+
buildTime: "2026-05-19T00:00:00.000Z",
|
|
9
|
+
env: "production",
|
|
10
|
+
bundles: {
|
|
11
|
+
home: {
|
|
12
|
+
js: "/.mandu/client/home.island.js",
|
|
13
|
+
dependencies: ["_runtime", "_react"],
|
|
14
|
+
priority: "visible",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
shared: {
|
|
18
|
+
runtime: "/.mandu/client/_runtime.js",
|
|
19
|
+
vendor: "/.mandu/client/_react.js",
|
|
20
|
+
router: "/.mandu/client/_router.js",
|
|
21
|
+
},
|
|
22
|
+
importMap: {
|
|
23
|
+
imports: {
|
|
24
|
+
react: "/.mandu/client/_react.js",
|
|
25
|
+
"react-dom": "/.mandu/client/_react-dom.js",
|
|
26
|
+
"react-dom/client": "/.mandu/client/_react-dom-client.js",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
4
30
|
|
|
5
31
|
describe("runtime page render response orchestration", () => {
|
|
6
32
|
it("pre-resolves async components on the non-streaming path", async () => {
|
|
@@ -51,4 +77,27 @@ describe("runtime page render response orchestration", () => {
|
|
|
51
77
|
expect(html).toContain("stream-page");
|
|
52
78
|
expect(html).toContain("Stream Page");
|
|
53
79
|
});
|
|
80
|
+
|
|
81
|
+
it("serializes non-streaming loaderData as the route server data exactly once", async () => {
|
|
82
|
+
const response = await renderPageResponse({
|
|
83
|
+
app: React.createElement("main", null, "hydrated-page"),
|
|
84
|
+
useStreaming: false,
|
|
85
|
+
title: "Hydrated Page",
|
|
86
|
+
headTags: "",
|
|
87
|
+
isDev: false,
|
|
88
|
+
routeId: "home",
|
|
89
|
+
routePattern: "/",
|
|
90
|
+
loaderData: { items: ["a", "b"] },
|
|
91
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
92
|
+
bundleManifest: HYDRATED_MANIFEST,
|
|
93
|
+
transitions: false,
|
|
94
|
+
prefetch: false,
|
|
95
|
+
spa: false,
|
|
96
|
+
devtools: false,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const html = await response.text();
|
|
100
|
+
expect(html).toContain('"home":{"serverData":{"items":["a","b"]}');
|
|
101
|
+
expect(html).not.toContain('"serverData":{"home"');
|
|
102
|
+
});
|
|
54
103
|
});
|
|
@@ -84,10 +84,6 @@ function renderNonStreamingPageResponse(
|
|
|
84
84
|
app: React.ReactElement,
|
|
85
85
|
options: PageRenderResponseOptions
|
|
86
86
|
): Response {
|
|
87
|
-
const serverData = options.loaderData
|
|
88
|
-
? { [options.routeId]: { serverData: options.loaderData } }
|
|
89
|
-
: undefined;
|
|
90
|
-
|
|
91
87
|
return renderSSR(app, {
|
|
92
88
|
title: options.title,
|
|
93
89
|
headTags: options.headTags,
|
|
@@ -96,7 +92,7 @@ function renderNonStreamingPageResponse(
|
|
|
96
92
|
routeId: options.routeId,
|
|
97
93
|
hydration: options.hydration,
|
|
98
94
|
bundleManifest: options.bundleManifest,
|
|
99
|
-
serverData,
|
|
95
|
+
serverData: options.loaderData,
|
|
100
96
|
enableClientRouter: true,
|
|
101
97
|
routePattern: options.routePattern,
|
|
102
98
|
cssPath: options.cssPath,
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -46,7 +46,7 @@ export interface SSROptions {
|
|
|
46
46
|
title?: string;
|
|
47
47
|
lang?: string;
|
|
48
48
|
/** 서버에서 로드한 데이터 (클라이언트로 전달) */
|
|
49
|
-
serverData?:
|
|
49
|
+
serverData?: unknown;
|
|
50
50
|
/** Hydration 설정 */
|
|
51
51
|
hydration?: HydrationConfig;
|
|
52
52
|
/** 번들 매니페스트 */
|
|
@@ -255,19 +255,26 @@ function generateHydrationScripts(
|
|
|
255
255
|
? Object.values(manifest.islands).filter((ib) => ib.route === routeId)
|
|
256
256
|
: [];
|
|
257
257
|
|
|
258
|
-
if (routeIslands.length > 0) {
|
|
259
|
-
for (const ib of routeIslands) {
|
|
260
|
-
const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
261
|
-
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
262
|
-
}
|
|
258
|
+
if (routeIslands.length > 0) {
|
|
259
|
+
for (const ib of routeIslands) {
|
|
260
|
+
const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
261
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
262
|
+
}
|
|
263
263
|
} else {
|
|
264
264
|
// Fallback: route-level bundle (backward compat)
|
|
265
265
|
const bundle = manifest.bundles[routeId];
|
|
266
266
|
if (bundle) {
|
|
267
267
|
const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
268
|
-
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
268
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (manifest.partials) {
|
|
273
|
+
for (const partial of Object.values(manifest.partials)) {
|
|
274
|
+
const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
275
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
271
278
|
|
|
272
279
|
// Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
|
|
273
280
|
if (manifest.shared.runtime) {
|
|
@@ -706,12 +713,14 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
706
713
|
const needsHydration =
|
|
707
714
|
hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
708
715
|
|
|
709
|
-
if (needsHydration && !islandPreWrapped) {
|
|
710
|
-
// v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
|
|
711
|
-
const bundle = bundleManifest.bundles[routeId];
|
|
712
|
-
const bundleSrc = bundle?.js;
|
|
713
|
-
|
|
714
|
-
|
|
716
|
+
if (needsHydration && !islandPreWrapped) {
|
|
717
|
+
// v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
|
|
718
|
+
const bundle = bundleManifest.bundles[routeId];
|
|
719
|
+
const bundleSrc = bundle?.js;
|
|
720
|
+
if (bundleSrc) {
|
|
721
|
+
content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
715
724
|
|
|
716
725
|
// Zero-JS 모드: island이 없는 페이지에서는 클라이언트 JS 번들을 전송하지 않음
|
|
717
726
|
// HMR/DevTools는 dev 환경에서만 유지 (CSS 핫리로드 등)
|
|
@@ -722,10 +731,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
722
731
|
|
|
723
732
|
if (needsHydration) {
|
|
724
733
|
// 서버 데이터 스크립트 (클라이언트 hydration에서 사용)
|
|
725
|
-
if (serverData && routeId) {
|
|
726
|
-
const wrappedData = {
|
|
727
|
-
[routeId]: {
|
|
728
|
-
serverData,
|
|
734
|
+
if (serverData !== undefined && routeId) {
|
|
735
|
+
const wrappedData = {
|
|
736
|
+
[routeId]: {
|
|
737
|
+
serverData,
|
|
729
738
|
timestamp: Date.now(),
|
|
730
739
|
},
|
|
731
740
|
};
|
|
@@ -862,11 +871,11 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
862
871
|
/**
|
|
863
872
|
* Client-side Routing: 현재 라우트 정보 스크립트 생성
|
|
864
873
|
*/
|
|
865
|
-
function generateRouteScript(
|
|
866
|
-
routeId: string,
|
|
867
|
-
pattern: string,
|
|
868
|
-
_serverData?:
|
|
869
|
-
): string {
|
|
874
|
+
function generateRouteScript(
|
|
875
|
+
routeId: string,
|
|
876
|
+
pattern: string,
|
|
877
|
+
_serverData?: unknown
|
|
878
|
+
): string {
|
|
870
879
|
const routeInfo = {
|
|
871
880
|
id: routeId,
|
|
872
881
|
pattern,
|
|
@@ -1167,12 +1176,12 @@ export function renderSSR(element: ReactElement, options: SSROptions = {}): Resp
|
|
|
1167
1176
|
*/
|
|
1168
1177
|
export async function renderWithHydration(
|
|
1169
1178
|
element: ReactElement,
|
|
1170
|
-
options: SSROptions & {
|
|
1171
|
-
routeId: string;
|
|
1172
|
-
serverData:
|
|
1173
|
-
hydration: HydrationConfig;
|
|
1174
|
-
bundleManifest: BundleManifest;
|
|
1175
|
-
}
|
|
1179
|
+
options: SSROptions & {
|
|
1180
|
+
routeId: string;
|
|
1181
|
+
serverData: unknown;
|
|
1182
|
+
hydration: HydrationConfig;
|
|
1183
|
+
bundleManifest: BundleManifest;
|
|
1184
|
+
}
|
|
1176
1185
|
): Promise<Response> {
|
|
1177
1186
|
const html = renderToHTML(element, options);
|
|
1178
1187
|
// Phase 7.2 R1 Agent C (H1) — same CSP header logic as renderSSR.
|
|
@@ -613,13 +613,16 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
613
613
|
}
|
|
614
614
|
</style>`;
|
|
615
615
|
|
|
616
|
-
let islandOpenTag = "";
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
616
|
+
let islandOpenTag = "";
|
|
617
|
+
const hasRouteBundle = !!(needsHydration && bundleManifest.bundles[routeId]?.js);
|
|
618
|
+
if (needsHydration) {
|
|
619
|
+
const bundle = bundleManifest.bundles[routeId];
|
|
620
|
+
const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
|
|
621
|
+
const priority = hydration.priority || "visible";
|
|
622
|
+
if (hasRouteBundle) {
|
|
623
|
+
islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">`;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
623
626
|
|
|
624
627
|
// Phase 7.1 R2 Agent D: Fast Refresh preamble. Must land in <head>
|
|
625
628
|
// BEFORE any island script evaluates — the stubs it installs for
|
|
@@ -697,7 +700,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
697
700
|
// 1~8: hydration이 필요한 경우에만 클라이언트 JS 관련 스크립트 삽입
|
|
698
701
|
if (needsHydration) {
|
|
699
702
|
// 1. Critical 데이터 스크립트 (즉시 사용 가능)
|
|
700
|
-
if (criticalData && routeId) {
|
|
703
|
+
if (criticalData !== undefined && routeId) {
|
|
701
704
|
const wrappedData = {
|
|
702
705
|
[routeId]: {
|
|
703
706
|
serverData: criticalData,
|
|
@@ -746,10 +749,16 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
746
749
|
|
|
747
750
|
// 6. Island modulepreload
|
|
748
751
|
const bundle = bundleManifest.bundles[routeId];
|
|
749
|
-
if (bundle) {
|
|
750
|
-
const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
751
|
-
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
752
|
-
}
|
|
752
|
+
if (bundle) {
|
|
753
|
+
const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
754
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
755
|
+
}
|
|
756
|
+
if (bundleManifest.partials) {
|
|
757
|
+
for (const partial of Object.values(bundleManifest.partials)) {
|
|
758
|
+
const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
759
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
753
762
|
|
|
754
763
|
// 7. Runtime 로드
|
|
755
764
|
if (bundleManifest.shared.runtime) {
|
|
@@ -783,7 +792,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
783
792
|
}
|
|
784
793
|
|
|
785
794
|
// Island wrapper 닫기 (hydration이 필요한 경우)
|
|
786
|
-
const islandCloseTag = needsHydration ? "</div>" : "";
|
|
795
|
+
const islandCloseTag = needsHydration && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
|
|
787
796
|
|
|
788
797
|
return `${islandCloseTag}</div>
|
|
789
798
|
${scripts.join("\n ")}`;
|