@mandujs/core 0.54.11 → 0.54.13
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 +208 -200
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/run-audit.ts +15 -15
- package/src/agent/__tests__/context.test.ts +49 -1
- package/src/agent/context.ts +535 -535
- package/src/agent/index.ts +6 -6
- package/src/agent/plan.ts +282 -282
- package/src/agent/repair.ts +171 -171
- package/src/agent/sync.ts +200 -200
- package/src/agent/types.ts +8 -0
- package/src/agent/verify.ts +100 -2
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/build-runner.ts +5 -4
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +20 -20
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/build.test.ts +73 -7
- package/src/bundler/build.ts +139 -31
- package/src/bundler/css.ts +42 -42
- package/src/bundler/manifest-schema.ts +21 -21
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/block-generated-imports.ts +13 -13
- package/src/bundler/types.ts +31 -31
- package/src/client/island.ts +79 -79
- package/src/config/validate.ts +1 -1
- package/src/contract/schema.ts +7 -0
- package/src/deploy/inference/context.ts +82 -82
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/error/formatter.ts +10 -1
- package/src/experimental/index.ts +10 -0
- package/src/filling/context.ts +17 -17
- package/src/filling/filling.ts +22 -1
- package/src/filling/index.ts +15 -1
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +210 -210
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/index.ts +11 -11
- package/src/index.ts +0 -10
- package/src/internal/index.ts +25 -0
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +6 -6
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +69 -33
- package/src/router/client-entry.ts +134 -74
- package/src/router/fs-routes.ts +24 -22
- package/src/router/fs-scanner.ts +21 -17
- package/src/router/fs-types.ts +8 -5
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +103 -103
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/image-feature.ts +15 -0
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/page-render-response.ts +106 -106
- package/src/runtime/rate-limit.ts +231 -0
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/scheduler-lifecycle.ts +64 -0
- package/src/runtime/server.ts +27 -295
- package/src/runtime/ssr.ts +59 -59
- package/src/runtime/static-files.ts +289 -289
- package/src/runtime/streaming-ssr.ts +22 -22
- package/src/spec/schema.ts +4 -3
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
|
@@ -40,10 +40,11 @@ describe("findClientComponentImports", () => {
|
|
|
40
40
|
}
|
|
41
41
|
`);
|
|
42
42
|
|
|
43
|
-
expect(routeClient).toEqual({
|
|
44
|
-
module: "@/client/pages/login/LoginPage.client",
|
|
45
|
-
localName: "LoginPage",
|
|
46
|
-
|
|
43
|
+
expect(routeClient).toEqual({
|
|
44
|
+
module: "@/client/pages/login/LoginPage.client",
|
|
45
|
+
localName: "LoginPage",
|
|
46
|
+
exportName: "default",
|
|
47
|
+
});
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
it("promotes a fragment wrapper with head-only elements and a bare client component", () => {
|
|
@@ -58,10 +59,11 @@ describe("findClientComponentImports", () => {
|
|
|
58
59
|
}
|
|
59
60
|
`);
|
|
60
61
|
|
|
61
|
-
expect(routeClient).toEqual({
|
|
62
|
-
module: "@/client/pages/home/HomeApp.client",
|
|
63
|
-
localName: "HomeApp",
|
|
64
|
-
|
|
62
|
+
expect(routeClient).toEqual({
|
|
63
|
+
module: "@/client/pages/home/HomeApp.client",
|
|
64
|
+
localName: "HomeApp",
|
|
65
|
+
exportName: "default",
|
|
66
|
+
});
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
it("detects a named-imported client component when the page returns only that component", () => {
|
|
@@ -73,11 +75,28 @@ describe("findClientComponentImports", () => {
|
|
|
73
75
|
}
|
|
74
76
|
`);
|
|
75
77
|
|
|
76
|
-
expect(routeClient).toEqual({
|
|
77
|
-
module: "@/client/pages/notifications/NotificationsPage.client",
|
|
78
|
-
localName: "NotificationsPage",
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
expect(routeClient).toEqual({
|
|
79
|
+
module: "@/client/pages/notifications/NotificationsPage.client",
|
|
80
|
+
localName: "NotificationsPage",
|
|
81
|
+
exportName: "NotificationsPage",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("preserves the source export name for aliased named imports", () => {
|
|
86
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
87
|
+
import { NotificationsPage as PageIsland } from "@/client/pages/notifications/NotificationsPage.client";
|
|
88
|
+
|
|
89
|
+
export default function Page() {
|
|
90
|
+
return <PageIsland />;
|
|
91
|
+
}
|
|
92
|
+
`);
|
|
93
|
+
|
|
94
|
+
expect(routeClient).toEqual({
|
|
95
|
+
module: "@/client/pages/notifications/NotificationsPage.client",
|
|
96
|
+
localName: "PageIsland",
|
|
97
|
+
exportName: "NotificationsPage",
|
|
98
|
+
});
|
|
99
|
+
});
|
|
81
100
|
|
|
82
101
|
it("promotes embedded client imports inside a larger server page", () => {
|
|
83
102
|
const routeClient = findRouteLevelClientComponentImport(`
|
|
@@ -91,10 +110,11 @@ describe("findClientComponentImports", () => {
|
|
|
91
110
|
}
|
|
92
111
|
`);
|
|
93
112
|
|
|
94
|
-
expect(routeClient).toEqual({
|
|
95
|
-
module: "@/client/pages/home/HomeApp.client",
|
|
96
|
-
localName: "HomeApp",
|
|
97
|
-
|
|
113
|
+
expect(routeClient).toEqual({
|
|
114
|
+
module: "@/client/pages/home/HomeApp.client",
|
|
115
|
+
localName: "HomeApp",
|
|
116
|
+
exportName: "default",
|
|
117
|
+
});
|
|
98
118
|
});
|
|
99
119
|
|
|
100
120
|
it("promotes client imports when the page wrapper passes props", () => {
|
|
@@ -106,10 +126,11 @@ describe("findClientComponentImports", () => {
|
|
|
106
126
|
}
|
|
107
127
|
`);
|
|
108
128
|
|
|
109
|
-
expect(routeClient).toEqual({
|
|
110
|
-
module: "@/client/pages/pledges/PledgePage.client",
|
|
111
|
-
localName: "PledgePage",
|
|
112
|
-
|
|
129
|
+
expect(routeClient).toEqual({
|
|
130
|
+
module: "@/client/pages/pledges/PledgePage.client",
|
|
131
|
+
localName: "PledgePage",
|
|
132
|
+
exportName: "default",
|
|
133
|
+
});
|
|
113
134
|
});
|
|
114
135
|
|
|
115
136
|
it("detects multiple rendered client imports in a server shell", () => {
|
|
@@ -130,14 +151,16 @@ describe("findClientComponentImports", () => {
|
|
|
130
151
|
`);
|
|
131
152
|
|
|
132
153
|
expect(routeClients).toEqual([
|
|
133
|
-
{
|
|
134
|
-
module: "@/client/widgets/comments-section/CommentsSection.client",
|
|
135
|
-
localName: "CommentsSection",
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
154
|
+
{
|
|
155
|
+
module: "@/client/widgets/comments-section/CommentsSection.client",
|
|
156
|
+
localName: "CommentsSection",
|
|
157
|
+
exportName: "CommentsSection",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
module: "@/client/widgets/pledge-actions/PledgeActions.client",
|
|
161
|
+
localName: "PledgeActions",
|
|
162
|
+
exportName: "PledgeActions",
|
|
163
|
+
},
|
|
141
164
|
]);
|
|
142
165
|
});
|
|
143
166
|
|
|
@@ -150,10 +173,23 @@ describe("findClientComponentImports", () => {
|
|
|
150
173
|
}
|
|
151
174
|
`);
|
|
152
175
|
|
|
153
|
-
expect(routeClient).toEqual({
|
|
154
|
-
module: "@/client/widgets/pledge-form/PledgeForm",
|
|
155
|
-
localName: "PledgeForm",
|
|
156
|
-
|
|
176
|
+
expect(routeClient).toEqual({
|
|
177
|
+
module: "@/client/widgets/pledge-form/PledgeForm",
|
|
178
|
+
localName: "PledgeForm",
|
|
179
|
+
exportName: "PledgeForm",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("does not promote shared UI primitives to route-level client entries", () => {
|
|
184
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
185
|
+
import { Button } from "@/client/shared/ui/button";
|
|
186
|
+
|
|
187
|
+
export default function Page() {
|
|
188
|
+
return <main><Button>Open</Button></main>;
|
|
189
|
+
}
|
|
190
|
+
`);
|
|
191
|
+
|
|
192
|
+
expect(routeClient).toBeNull();
|
|
157
193
|
});
|
|
158
194
|
|
|
159
195
|
it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
|
|
@@ -8,12 +8,23 @@ export interface ClientComponentImport {
|
|
|
8
8
|
names: string[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export interface RouteLevelClientComponentImport {
|
|
12
|
-
module: string;
|
|
13
|
-
localName: string;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
export interface RouteLevelClientComponentImport {
|
|
12
|
+
module: string;
|
|
13
|
+
localName: string;
|
|
14
|
+
exportName: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CLIENT_ENTRY_SPECIFIER_PATTERN = /\.(?:client|island)(?:\.[tj]sx?)?$/;
|
|
18
|
+
const DEFAULT_EXPORT_NAME = "default";
|
|
19
|
+
|
|
20
|
+
interface ComponentImportSpecifier {
|
|
21
|
+
importedName: string;
|
|
22
|
+
localName: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ComponentImportRecord extends ClientComponentImport {
|
|
26
|
+
specifiers: ComponentImportSpecifier[];
|
|
27
|
+
}
|
|
17
28
|
|
|
18
29
|
export function normalizeRouteModulePath(value: string | undefined): string {
|
|
19
30
|
return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -45,45 +56,64 @@ async function readRouteModule(rootDir: string, modulePath: string): Promise<str
|
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
export function findClientComponentImports(source: string): ClientComponentImport[] {
|
|
49
|
-
return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
export function findClientComponentImports(source: string): ClientComponentImport[] {
|
|
60
|
+
return findComponentImportRecords(source)
|
|
61
|
+
.filter((entry) => clientSpecifierLooksBrowserOnly(entry.module))
|
|
62
|
+
.map(toPublicClientComponentImport);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toPublicClientComponentImport(entry: ComponentImportRecord): ClientComponentImport {
|
|
66
|
+
return {
|
|
67
|
+
module: entry.module,
|
|
68
|
+
kind: entry.kind,
|
|
69
|
+
names: entry.names,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findComponentImportRecords(source: string): ComponentImportRecord[] {
|
|
74
|
+
const imports: ComponentImportRecord[] = [];
|
|
75
|
+
const importFromPattern = /import\s+(?!type\b)([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
|
|
76
|
+
const sideEffectPattern = /import\s+["']([^"']+)["']/g;
|
|
56
77
|
|
|
57
78
|
for (const match of source.matchAll(importFromPattern)) {
|
|
58
79
|
const clause = (match[1] ?? "").trim();
|
|
59
|
-
const module = match[2] ?? "";
|
|
60
|
-
const names: string[] = [];
|
|
61
|
-
|
|
62
|
-
let
|
|
63
|
-
let
|
|
80
|
+
const module = match[2] ?? "";
|
|
81
|
+
const names: string[] = [];
|
|
82
|
+
const specifiers: ComponentImportSpecifier[] = [];
|
|
83
|
+
let hasDefault = false;
|
|
84
|
+
let hasNamed = false;
|
|
85
|
+
let hasNamespace = false;
|
|
64
86
|
|
|
65
87
|
const namedMatch = clause.match(/\{([^}]*)\}/);
|
|
66
88
|
if (namedMatch) {
|
|
67
89
|
hasNamed = true;
|
|
68
90
|
for (const rawName of namedMatch[1].split(",")) {
|
|
69
|
-
const name = rawName.trim();
|
|
70
|
-
if (!name) continue;
|
|
71
|
-
const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
91
|
+
const name = rawName.trim();
|
|
92
|
+
if (!name) continue;
|
|
93
|
+
const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
|
|
94
|
+
const importedName = parts[0] ?? "";
|
|
95
|
+
const localName = parts[1] ?? importedName;
|
|
96
|
+
names.push(localName);
|
|
97
|
+
specifiers.push({ importedName, localName });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (/\*\s+as\s+/.test(clause)) {
|
|
102
|
+
hasNamespace = true;
|
|
103
|
+
const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
|
|
104
|
+
if (namespaceMatch?.[1]) {
|
|
105
|
+
names.push(namespaceMatch[1]);
|
|
106
|
+
specifiers.push({ importedName: "*", localName: namespaceMatch[1] });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
|
|
111
|
+
if (beforeNamed && !beforeNamed.startsWith("*")) {
|
|
112
|
+
hasDefault = true;
|
|
113
|
+
const localName = beforeNamed.split(",")[0].trim();
|
|
114
|
+
names.push(localName);
|
|
115
|
+
specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName });
|
|
116
|
+
}
|
|
87
117
|
|
|
88
118
|
const kind =
|
|
89
119
|
(hasDefault && (hasNamed || hasNamespace))
|
|
@@ -94,33 +124,51 @@ function findComponentImports(source: string): ClientComponentImport[] {
|
|
|
94
124
|
? "namespace"
|
|
95
125
|
: "default";
|
|
96
126
|
|
|
97
|
-
imports.push({ module, kind, names });
|
|
98
|
-
}
|
|
127
|
+
imports.push({ module, kind, names, specifiers });
|
|
128
|
+
}
|
|
99
129
|
|
|
100
130
|
for (const match of source.matchAll(sideEffectPattern)) {
|
|
101
131
|
const module = match[1] ?? "";
|
|
102
132
|
if (imports.some((entry) => entry.module === module)) continue;
|
|
103
|
-
imports.push({ module, kind: "side-effect", names: [] });
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return imports;
|
|
133
|
+
imports.push({ module, kind: "side-effect", names: [], specifiers: [] });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return imports;
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
|
|
110
140
|
return findRouteLevelClientComponentImports(source)[0] ?? null;
|
|
111
141
|
}
|
|
112
142
|
|
|
113
|
-
export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
|
|
114
|
-
const candidates =
|
|
115
|
-
entry.
|
|
116
|
-
.
|
|
117
|
-
|
|
118
|
-
|
|
143
|
+
export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
|
|
144
|
+
const candidates = findComponentImportRecords(source).flatMap((entry) =>
|
|
145
|
+
isRouteLevelClientEntrySpecifier(entry.module)
|
|
146
|
+
? entry.specifiers
|
|
147
|
+
.filter((specifier) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(specifier.localName))
|
|
148
|
+
.map((specifier) => ({
|
|
149
|
+
module: entry.module,
|
|
150
|
+
localName: specifier.localName,
|
|
151
|
+
exportName: specifier.importedName,
|
|
152
|
+
}))
|
|
153
|
+
: []
|
|
154
|
+
);
|
|
119
155
|
|
|
120
156
|
if (candidates.length === 0) return [];
|
|
121
157
|
return defaultExportRendersClientComponents(source, candidates);
|
|
122
158
|
}
|
|
123
159
|
|
|
160
|
+
function isRouteLevelClientEntrySpecifier(specifier: string): boolean {
|
|
161
|
+
const normalized = specifier.replace(/\\/g, "/");
|
|
162
|
+
if (
|
|
163
|
+
normalized.includes("/client/shared/") ||
|
|
164
|
+
normalized.startsWith("@/client/shared/") ||
|
|
165
|
+
normalized.startsWith("src/client/shared/")
|
|
166
|
+
) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
export async function resolveClientImportModulePath(
|
|
125
173
|
rootDir: string,
|
|
126
174
|
importerModule: string,
|
|
@@ -138,31 +186,42 @@ export async function resolveClientImportModulePath(
|
|
|
138
186
|
return null;
|
|
139
187
|
}
|
|
140
188
|
|
|
141
|
-
export async function resolveRouteLevelClientEntryPath(
|
|
142
|
-
rootDir: string,
|
|
143
|
-
routeModule: string,
|
|
144
|
-
source: string,
|
|
145
|
-
): Promise<string | null> {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
189
|
+
export async function resolveRouteLevelClientEntryPath(
|
|
190
|
+
rootDir: string,
|
|
191
|
+
routeModule: string,
|
|
192
|
+
source: string,
|
|
193
|
+
): Promise<string | null> {
|
|
194
|
+
return (await resolveRouteLevelClientEntry(rootDir, routeModule, source))?.modulePath ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function resolveRouteLevelClientEntry(
|
|
198
|
+
rootDir: string,
|
|
199
|
+
routeModule: string,
|
|
200
|
+
source: string,
|
|
201
|
+
): Promise<{ modulePath: string; exportName: string } | null> {
|
|
202
|
+
const routeLevelClientImports = findRouteLevelClientComponentImports(source);
|
|
203
|
+
for (const routeLevelClientImport of routeLevelClientImports) {
|
|
204
|
+
const resolved = await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module);
|
|
205
|
+
if (!resolved) continue;
|
|
206
|
+
if (clientSpecifierLooksBrowserOnly(routeLevelClientImport.module)) {
|
|
207
|
+
return { modulePath: resolved, exportName: routeLevelClientImport.exportName };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const importedSource = await readRouteModule(rootDir, resolved);
|
|
211
|
+
if (importedSource !== null && hasUseClientDirective(importedSource)) {
|
|
212
|
+
return { modulePath: resolved, exportName: routeLevelClientImport.exportName };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
160
218
|
|
|
161
219
|
export async function shouldPreserveExistingClientModule(
|
|
162
220
|
route: RouteSpec,
|
|
163
221
|
clientModule: string,
|
|
164
222
|
rootDir: string,
|
|
165
223
|
): Promise<boolean> {
|
|
224
|
+
if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
|
|
166
225
|
const source = await readRouteModule(rootDir, clientModule);
|
|
167
226
|
if (source === null) return false;
|
|
168
227
|
if (hasUseServerDirective(source)) return false;
|
|
@@ -446,12 +505,13 @@ export async function validateClientModuleForBrowserBundle(
|
|
|
446
505
|
return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
|
|
447
506
|
}
|
|
448
507
|
|
|
449
|
-
if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
|
|
450
|
-
const realClientEntry = await
|
|
451
|
-
if (realClientEntry) {
|
|
452
|
-
route.clientModule = realClientEntry;
|
|
453
|
-
|
|
454
|
-
|
|
508
|
+
if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
|
|
509
|
+
const realClientEntry = await resolveRouteLevelClientEntry(rootDir, route.clientModule, source);
|
|
510
|
+
if (realClientEntry) {
|
|
511
|
+
route.clientModule = realClientEntry.modulePath;
|
|
512
|
+
route.clientExportName = realClientEntry.exportName;
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
455
515
|
return (
|
|
456
516
|
`[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
|
|
457
517
|
`but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
|
package/src/router/fs-routes.ts
CHANGED
|
@@ -14,11 +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";
|
|
21
|
-
import { shouldPreserveExistingClientModule } from "./client-entry";
|
|
17
|
+
import {
|
|
18
|
+
runOnRouteRegistered,
|
|
19
|
+
runOnManifestBuilt,
|
|
20
|
+
} from "../plugins/runner";
|
|
21
|
+
import { shouldPreserveExistingClientModule } from "./client-entry";
|
|
22
22
|
|
|
23
23
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
24
|
// Types
|
|
@@ -87,12 +87,13 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
|
|
|
87
87
|
...base,
|
|
88
88
|
kind: "page" as const,
|
|
89
89
|
componentModule: normalizePath(fsRoute.componentModule ?? ""),
|
|
90
|
-
...(fsRoute.clientModule
|
|
91
|
-
? {
|
|
92
|
-
clientModule: normalizePath(fsRoute.clientModule),
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
...(fsRoute.clientModule
|
|
91
|
+
? {
|
|
92
|
+
clientModule: normalizePath(fsRoute.clientModule),
|
|
93
|
+
...(fsRoute.clientExportName ? { clientExportName: fsRoute.clientExportName } : {}),
|
|
94
|
+
hydration: fsRoute.hydration ?? {
|
|
95
|
+
strategy: "island" as const,
|
|
96
|
+
priority: "immediate" as const,
|
|
96
97
|
preload: false,
|
|
97
98
|
},
|
|
98
99
|
}
|
|
@@ -309,17 +310,18 @@ export async function generateManifest(
|
|
|
309
310
|
for (const route of manifest.routes) {
|
|
310
311
|
const prev = existingMap.get(route.id);
|
|
311
312
|
if (!prev) continue;
|
|
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
|
-
|
|
313
|
+
// 사용자가 설정한 clientModule/hydration 보존.
|
|
314
|
+
// If app/page.tsx used to be a client page and later becomes a server
|
|
315
|
+
// page, preserving the old clientModule would leak the server graph into
|
|
316
|
+
// the browser bundle.
|
|
317
|
+
if (
|
|
318
|
+
prev.clientModule &&
|
|
319
|
+
!route.clientModule &&
|
|
320
|
+
await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
|
|
321
|
+
) {
|
|
322
|
+
route.clientModule = prev.clientModule;
|
|
323
|
+
route.clientExportName = prev.clientExportName;
|
|
324
|
+
}
|
|
323
325
|
if (prev.hydration && !route.hydration) {
|
|
324
326
|
route.hydration = prev.hydration;
|
|
325
327
|
}
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -30,10 +30,10 @@ import {
|
|
|
30
30
|
} from "./fs-patterns";
|
|
31
31
|
import { mark, measure } from "../perf";
|
|
32
32
|
import { METADATA_ROUTES } from "../routes/types";
|
|
33
|
-
import {
|
|
34
|
-
hasUseClientDirective,
|
|
35
|
-
|
|
36
|
-
} from "./client-entry";
|
|
33
|
+
import {
|
|
34
|
+
hasUseClientDirective,
|
|
35
|
+
resolveRouteLevelClientEntry,
|
|
36
|
+
} from "./client-entry";
|
|
37
37
|
|
|
38
38
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
39
39
|
// Scanner Class
|
|
@@ -364,9 +364,10 @@ export class FSScanner {
|
|
|
364
364
|
const dirPath = this.getDirPath(file.relativePath);
|
|
365
365
|
const islands = islandMap.get(dirPath);
|
|
366
366
|
|
|
367
|
-
// clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
|
|
368
|
-
let clientModule: string | undefined;
|
|
369
|
-
let
|
|
367
|
+
// clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
|
|
368
|
+
let clientModule: string | undefined;
|
|
369
|
+
let clientExportName: string | undefined;
|
|
370
|
+
let pageFileContent: string | null = null;
|
|
370
371
|
|
|
371
372
|
if (file.type === "page") {
|
|
372
373
|
try {
|
|
@@ -395,12 +396,14 @@ export class FSScanner {
|
|
|
395
396
|
} else if (file.type === "page" && pageFileContent) {
|
|
396
397
|
// page 파일 자체에서 "use client" 확인
|
|
397
398
|
const hasUseClient = hasUseClientDirective(pageFileContent);
|
|
398
|
-
if (hasUseClient) {
|
|
399
|
-
clientModule = modulePath;
|
|
400
|
-
} else {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
399
|
+
if (hasUseClient) {
|
|
400
|
+
clientModule = modulePath;
|
|
401
|
+
} else {
|
|
402
|
+
const routeClientEntry = await resolveRouteLevelClientEntry(rootDir, modulePath, pageFileContent);
|
|
403
|
+
clientModule = routeClientEntry?.modulePath;
|
|
404
|
+
clientExportName = routeClientEntry?.exportName;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
404
407
|
|
|
405
408
|
// 로딩/에러/404 모듈 찾기 — nearest-ancestor resolution.
|
|
406
409
|
// Phase 18.β: `not-found.tsx` joins `loading.tsx`/`error.tsx` in
|
|
@@ -415,10 +418,11 @@ export class FSScanner {
|
|
|
415
418
|
segments: file.segments,
|
|
416
419
|
pattern,
|
|
417
420
|
kind: file.type === "page" ? "page" : "api",
|
|
418
|
-
module: modulePath,
|
|
419
|
-
componentModule: file.type === "page" ? modulePath : undefined,
|
|
420
|
-
clientModule,
|
|
421
|
-
|
|
421
|
+
module: modulePath,
|
|
422
|
+
componentModule: file.type === "page" ? modulePath : undefined,
|
|
423
|
+
clientModule,
|
|
424
|
+
clientExportName,
|
|
425
|
+
layoutChain,
|
|
422
426
|
loadingModule,
|
|
423
427
|
errorModule,
|
|
424
428
|
notFoundModule,
|
package/src/router/fs-types.ts
CHANGED
|
@@ -117,11 +117,14 @@ export interface FSRouteConfig {
|
|
|
117
117
|
/** API 핸들러 모듈 경로 */
|
|
118
118
|
module: string;
|
|
119
119
|
|
|
120
|
-
/** Island 컴포넌트 모듈 경로 */
|
|
121
|
-
clientModule?: string;
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
|
|
120
|
+
/** Island 컴포넌트 모듈 경로 */
|
|
121
|
+
clientModule?: string;
|
|
122
|
+
|
|
123
|
+
/** Named export used by the route-level client module, if not default. */
|
|
124
|
+
clientExportName?: string;
|
|
125
|
+
|
|
126
|
+
/** 적용할 레이아웃 체인 */
|
|
127
|
+
layoutChain: string[];
|
|
125
128
|
|
|
126
129
|
/** 로딩 UI 모듈 경로 */
|
|
127
130
|
loadingModule?: string;
|