@mandujs/core 0.54.2 → 0.54.4
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 +5 -2
- package/scripts/postinstall-lock.ts +153 -0
- package/src/agent/__tests__/context.test.ts +237 -0
- package/src/agent/context.ts +535 -0
- package/src/agent/index.ts +6 -0
- package/src/agent/plan.ts +283 -0
- package/src/agent/repair.ts +172 -0
- package/src/agent/sync.ts +200 -0
- package/src/agent/types.ts +308 -0
- package/src/agent/verify.ts +406 -0
- package/src/bundler/__tests__/build-runner.ts +33 -13
- package/src/bundler/__tests__/cold-start.test.ts +35 -7
- package/src/bundler/__tests__/css.test.ts +20 -0
- package/src/bundler/analyzer.ts +15 -7
- package/src/bundler/build.test.ts +53 -11
- package/src/bundler/build.ts +447 -185
- package/src/bundler/css.ts +42 -12
- 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/index.ts +3 -2
- package/src/router/client-entry.ts +71 -0
- package/src/router/fs-routes.ts +16 -8
- package/src/router/fs-scanner.ts +4 -3
- 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
package/src/bundler/css.ts
CHANGED
|
@@ -18,14 +18,38 @@ import fs from "fs/promises";
|
|
|
18
18
|
import { watch as fsWatch, type FSWatcher } from "fs";
|
|
19
19
|
import { withPerf } from "../perf";
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
* Tailwind CLI 실행 명령어를 결정한다.
|
|
23
|
-
* Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152)
|
|
24
|
-
* process.execPath (절대 경로)를
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Tailwind CLI 실행 명령어를 결정한다.
|
|
23
|
+
* Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152)
|
|
24
|
+
* `bun run mandu` 환경에서는 process.execPath (bun 절대 경로)를 사용한다.
|
|
25
|
+
* Standalone Mandu binary에서는 process.execPath가 mandu.exe를 가리키므로
|
|
26
|
+
* `mandu x @tailwindcss/cli`로 오해석된다. 이 경우 PATH에서 Bun을 찾는다.
|
|
27
|
+
*/
|
|
28
|
+
function isBunExecutable(executablePath: string | undefined): boolean {
|
|
29
|
+
if (!executablePath) return false;
|
|
30
|
+
const base = path.basename(executablePath).toLowerCase();
|
|
31
|
+
return base === "bun" || base === "bun.exe";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type WhichExecutable = (command: string) => string | null | undefined;
|
|
35
|
+
|
|
36
|
+
function resolveBunExecutable(
|
|
37
|
+
execPath = process.execPath,
|
|
38
|
+
which: WhichExecutable = (command) => Bun.which(command),
|
|
39
|
+
): string {
|
|
40
|
+
if (isBunExecutable(execPath)) return execPath;
|
|
41
|
+
const fromPath = which("bun");
|
|
42
|
+
if (fromPath) return fromPath;
|
|
43
|
+
return process.platform === "win32" ? "bun.exe" : "bun";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getTailwindCommand(
|
|
47
|
+
args: string[],
|
|
48
|
+
execPath = process.execPath,
|
|
49
|
+
which?: WhichExecutable,
|
|
50
|
+
): string[] {
|
|
51
|
+
return [resolveBunExecutable(execPath, which), "x", ...args];
|
|
52
|
+
}
|
|
29
53
|
|
|
30
54
|
// ========== Types ==========
|
|
31
55
|
|
|
@@ -320,7 +344,13 @@ export function getCSSServerPath(): string {
|
|
|
320
344
|
/**
|
|
321
345
|
* CSS 링크 태그 생성
|
|
322
346
|
*/
|
|
323
|
-
export function generateCSSLinkTag(isDev: boolean = false): string {
|
|
324
|
-
const cacheBust = isDev ? `?t=${Date.now()}` : "";
|
|
325
|
-
return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
|
|
326
|
-
}
|
|
347
|
+
export function generateCSSLinkTag(isDev: boolean = false): string {
|
|
348
|
+
const cacheBust = isDev ? `?t=${Date.now()}` : "";
|
|
349
|
+
return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export const __private = {
|
|
353
|
+
getTailwindCommand,
|
|
354
|
+
isBunExecutable,
|
|
355
|
+
resolveBunExecutable,
|
|
356
|
+
};
|
|
@@ -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
|
package/src/index.ts
CHANGED
|
@@ -21,8 +21,9 @@ export const __MANDU_CORE_VERSION__: string = (() => {
|
|
|
21
21
|
}
|
|
22
22
|
})();
|
|
23
23
|
|
|
24
|
-
export * from "./spec";
|
|
25
|
-
export * from "./
|
|
24
|
+
export * from "./spec";
|
|
25
|
+
export * from "./agent";
|
|
26
|
+
export * from "./runtime";
|
|
26
27
|
export * from "./generator";
|
|
27
28
|
export * from "./guard";
|
|
28
29
|
export * from "./report";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { RouteSpec } from "../spec/schema";
|
|
4
|
+
|
|
5
|
+
export function normalizeRouteModulePath(value: string | undefined): string {
|
|
6
|
+
return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hasUseClientDirective(source: string): boolean {
|
|
10
|
+
return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use client["']\s*;?/.test(source);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hasUseServerDirective(source: string): boolean {
|
|
14
|
+
return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use server["']\s*;?/.test(source);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clientModuleIsRouteComponent(route: RouteSpec, clientModule = route.clientModule): boolean {
|
|
18
|
+
if (route.kind !== "page" || !clientModule) return false;
|
|
19
|
+
|
|
20
|
+
const client = normalizeRouteModulePath(clientModule);
|
|
21
|
+
return (
|
|
22
|
+
client === normalizeRouteModulePath(route.componentModule) ||
|
|
23
|
+
client === normalizeRouteModulePath(route.module)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readRouteModule(rootDir: string, modulePath: string): Promise<string | null> {
|
|
28
|
+
try {
|
|
29
|
+
return await readFile(path.resolve(rootDir, modulePath), "utf-8");
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function shouldPreserveExistingClientModule(
|
|
36
|
+
route: RouteSpec,
|
|
37
|
+
clientModule: string,
|
|
38
|
+
rootDir: string,
|
|
39
|
+
): Promise<boolean> {
|
|
40
|
+
const source = await readRouteModule(rootDir, clientModule);
|
|
41
|
+
if (source === null) return false;
|
|
42
|
+
if (hasUseServerDirective(source)) return false;
|
|
43
|
+
if (clientModuleIsRouteComponent(route, clientModule)) {
|
|
44
|
+
return hasUseClientDirective(source);
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function validateClientModuleForBrowserBundle(
|
|
50
|
+
route: RouteSpec,
|
|
51
|
+
rootDir: string,
|
|
52
|
+
): Promise<string | null> {
|
|
53
|
+
if (!route.clientModule) return null;
|
|
54
|
+
|
|
55
|
+
const source = await readRouteModule(rootDir, route.clientModule);
|
|
56
|
+
if (source === null) return null;
|
|
57
|
+
|
|
58
|
+
if (hasUseServerDirective(source)) {
|
|
59
|
+
return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
|
|
63
|
+
return (
|
|
64
|
+
`[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
|
|
65
|
+
`but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
|
|
66
|
+
`Remove the stale clientModule from .mandu/routes.manifest.json or use a *.partial.tsx / spec/slots/${route.id}.client.tsx client entry.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
package/src/router/fs-routes.ts
CHANGED
|
@@ -14,10 +14,11 @@ import { DEFAULT_SCANNER_CONFIG } from "./fs-types";
|
|
|
14
14
|
import { scanRoutes } from "./fs-scanner";
|
|
15
15
|
import { loadManduConfig } from "../config";
|
|
16
16
|
import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
|
|
17
|
-
import {
|
|
18
|
-
runOnRouteRegistered,
|
|
19
|
-
runOnManifestBuilt,
|
|
20
|
-
} from "../plugins/runner";
|
|
17
|
+
import {
|
|
18
|
+
runOnRouteRegistered,
|
|
19
|
+
runOnManifestBuilt,
|
|
20
|
+
} from "../plugins/runner";
|
|
21
|
+
import { shouldPreserveExistingClientModule } from "./client-entry";
|
|
21
22
|
|
|
22
23
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
23
24
|
// Types
|
|
@@ -308,10 +309,17 @@ export async function generateManifest(
|
|
|
308
309
|
for (const route of manifest.routes) {
|
|
309
310
|
const prev = existingMap.get(route.id);
|
|
310
311
|
if (!prev) continue;
|
|
311
|
-
// 사용자가 설정한 clientModule/hydration
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
// 사용자가 설정한 clientModule/hydration 보존.
|
|
313
|
+
// If app/page.tsx used to be a client page and later becomes a server
|
|
314
|
+
// page, preserving the old clientModule would leak the server graph into
|
|
315
|
+
// the browser bundle.
|
|
316
|
+
if (
|
|
317
|
+
prev.clientModule &&
|
|
318
|
+
!route.clientModule &&
|
|
319
|
+
await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
|
|
320
|
+
) {
|
|
321
|
+
route.clientModule = prev.clientModule;
|
|
322
|
+
}
|
|
315
323
|
if (prev.hydration && !route.hydration) {
|
|
316
324
|
route.hydration = prev.hydration;
|
|
317
325
|
}
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -28,8 +28,9 @@ import {
|
|
|
28
28
|
sortRoutesByPriority,
|
|
29
29
|
getPatternShape,
|
|
30
30
|
} from "./fs-patterns";
|
|
31
|
-
import { mark, measure } from "../perf";
|
|
32
|
-
import { METADATA_ROUTES } from "../routes/types";
|
|
31
|
+
import { mark, measure } from "../perf";
|
|
32
|
+
import { METADATA_ROUTES } from "../routes/types";
|
|
33
|
+
import { hasUseClientDirective } from "./client-entry";
|
|
33
34
|
|
|
34
35
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
35
36
|
// Scanner Class
|
|
@@ -390,7 +391,7 @@ export class FSScanner {
|
|
|
390
391
|
}
|
|
391
392
|
} else if (file.type === "page" && pageFileContent) {
|
|
392
393
|
// page 파일 자체에서 "use client" 확인
|
|
393
|
-
const hasUseClient =
|
|
394
|
+
const hasUseClient = hasUseClientDirective(pageFileContent);
|
|
394
395
|
if (hasUseClient) {
|
|
395
396
|
clientModule = modulePath;
|
|
396
397
|
}
|
|
@@ -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,
|