@mandujs/core 0.54.12 → 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 +9 -1
- 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 +66 -15
- package/src/bundler/build.ts +165 -103
- 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
|
@@ -28,13 +28,13 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
|
28
28
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
29
29
|
import { tmpdir } from "os";
|
|
30
30
|
import path from "path";
|
|
31
|
-
import type { RouteSpec, RoutesManifest } from "../../spec/schema";
|
|
32
|
-
import {
|
|
33
|
-
_testOnly_scanIslandFiles,
|
|
34
|
-
_testOnly_scanPartialFiles,
|
|
35
|
-
_testOnly_getHydratedRoutes,
|
|
36
|
-
_testOnly_getHydrationRoutesMissingClientModule,
|
|
37
|
-
} from "../build";
|
|
31
|
+
import type { RouteSpec, RoutesManifest } from "../../spec/schema";
|
|
32
|
+
import {
|
|
33
|
+
_testOnly_scanIslandFiles,
|
|
34
|
+
_testOnly_scanPartialFiles,
|
|
35
|
+
_testOnly_getHydratedRoutes,
|
|
36
|
+
_testOnly_getHydrationRoutesMissingClientModule,
|
|
37
|
+
} from "../build";
|
|
38
38
|
import { HMR_PERF } from "../../perf/hmr-markers";
|
|
39
39
|
import {
|
|
40
40
|
_resetCacheForTesting as _resetPerfCache,
|
|
@@ -374,7 +374,7 @@ describe("Phase 7.1 R1 Agent C — getHydratedRoutes filter", () => {
|
|
|
374
374
|
});
|
|
375
375
|
});
|
|
376
376
|
|
|
377
|
-
describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", () => {
|
|
377
|
+
describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", () => {
|
|
378
378
|
let project: ReturnType<typeof createProject>;
|
|
379
379
|
|
|
380
380
|
beforeEach(() => {
|
|
@@ -502,55 +502,55 @@ describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", (
|
|
|
502
502
|
];
|
|
503
503
|
const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
|
|
504
504
|
expect(result).toEqual([]);
|
|
505
|
-
});
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
describe("hydration route validation", () => {
|
|
509
|
-
it("finds pages that request hydration without a client module", () => {
|
|
510
|
-
const manifest = {
|
|
511
|
-
version: 1,
|
|
512
|
-
routes: [
|
|
513
|
-
{
|
|
514
|
-
id: "login",
|
|
515
|
-
pattern: "/login",
|
|
516
|
-
kind: "page",
|
|
517
|
-
module: "app/login/page.tsx",
|
|
518
|
-
componentModule: "app/login/page.tsx",
|
|
519
|
-
hydration: { strategy: "full", priority: "immediate", preload: false },
|
|
520
|
-
},
|
|
521
|
-
pureSsrRoute("about", "/about", "app/about"),
|
|
522
|
-
pageRoute("dashboard", "/dashboard", "app/dashboard"),
|
|
523
|
-
],
|
|
524
|
-
} as RoutesManifest;
|
|
525
|
-
|
|
526
|
-
const missing = _testOnly_getHydrationRoutesMissingClientModule(manifest);
|
|
527
|
-
expect(missing.map((route) => route.id)).toEqual(["login"]);
|
|
528
|
-
});
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
describe("partial bundle scan", () => {
|
|
532
|
-
let project: ReturnType<typeof createProject>;
|
|
533
|
-
|
|
534
|
-
beforeEach(() => {
|
|
535
|
-
project = createProject();
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
afterEach(() => {
|
|
539
|
-
try {
|
|
540
|
-
rmSync(project.rootDir, { recursive: true, force: true });
|
|
541
|
-
} catch {
|
|
542
|
-
/* Windows lock tolerance */
|
|
543
|
-
}
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
it("discovers *.partial.tsx files outside .mandu and node_modules", async () => {
|
|
547
|
-
project.writeIslandFile("app", "Home.partial.tsx");
|
|
548
|
-
project.writeIslandFile(".mandu/client", "Stale.partial.tsx");
|
|
549
|
-
project.writeIslandFile("node_modules/pkg", "Ignored.partial.tsx");
|
|
550
|
-
|
|
551
|
-
const result = await _testOnly_scanPartialFiles(project.rootDir);
|
|
552
|
-
|
|
553
|
-
expect(result.map((entry) => entry.name)).toEqual(["Home"]);
|
|
554
|
-
expect(result[0].priority).toBe("visible");
|
|
555
|
-
});
|
|
556
|
-
});
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("hydration route validation", () => {
|
|
509
|
+
it("finds pages that request hydration without a client module", () => {
|
|
510
|
+
const manifest = {
|
|
511
|
+
version: 1,
|
|
512
|
+
routes: [
|
|
513
|
+
{
|
|
514
|
+
id: "login",
|
|
515
|
+
pattern: "/login",
|
|
516
|
+
kind: "page",
|
|
517
|
+
module: "app/login/page.tsx",
|
|
518
|
+
componentModule: "app/login/page.tsx",
|
|
519
|
+
hydration: { strategy: "full", priority: "immediate", preload: false },
|
|
520
|
+
},
|
|
521
|
+
pureSsrRoute("about", "/about", "app/about"),
|
|
522
|
+
pageRoute("dashboard", "/dashboard", "app/dashboard"),
|
|
523
|
+
],
|
|
524
|
+
} as RoutesManifest;
|
|
525
|
+
|
|
526
|
+
const missing = _testOnly_getHydrationRoutesMissingClientModule(manifest);
|
|
527
|
+
expect(missing.map((route) => route.id)).toEqual(["login"]);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("partial bundle scan", () => {
|
|
532
|
+
let project: ReturnType<typeof createProject>;
|
|
533
|
+
|
|
534
|
+
beforeEach(() => {
|
|
535
|
+
project = createProject();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
afterEach(() => {
|
|
539
|
+
try {
|
|
540
|
+
rmSync(project.rootDir, { recursive: true, force: true });
|
|
541
|
+
} catch {
|
|
542
|
+
/* Windows lock tolerance */
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("discovers *.partial.tsx files outside .mandu and node_modules", async () => {
|
|
547
|
+
project.writeIslandFile("app", "Home.partial.tsx");
|
|
548
|
+
project.writeIslandFile(".mandu/client", "Stale.partial.tsx");
|
|
549
|
+
project.writeIslandFile("node_modules/pkg", "Ignored.partial.tsx");
|
|
550
|
+
|
|
551
|
+
const result = await _testOnly_scanPartialFiles(project.rootDir);
|
|
552
|
+
|
|
553
|
+
expect(result.map((entry) => entry.name)).toEqual(["Home"]);
|
|
554
|
+
expect(result[0].priority).toBe("visible");
|
|
555
|
+
});
|
|
556
|
+
});
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { __private } from "../css";
|
|
3
|
-
|
|
4
|
-
describe("CSS Tailwind command resolution", () => {
|
|
5
|
-
test("uses process.execPath when it is Bun", () => {
|
|
6
|
-
expect(
|
|
7
|
-
__private.getTailwindCommand(["@tailwindcss/cli"], "C:\\tools\\bun.exe"),
|
|
8
|
-
).toEqual(["C:\\tools\\bun.exe", "x", "@tailwindcss/cli"]);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test("does not treat standalone mandu.exe as Bun", () => {
|
|
12
|
-
expect(
|
|
13
|
-
__private.getTailwindCommand(
|
|
14
|
-
["@tailwindcss/cli"],
|
|
15
|
-
"C:\\Users\\User\\AppData\\Local\\Mandu\\bin\\mandu.exe",
|
|
16
|
-
() => "C:\\Users\\User\\.bun\\bin\\bun.exe",
|
|
17
|
-
),
|
|
18
|
-
).toEqual(["C:\\Users\\User\\.bun\\bin\\bun.exe", "x", "@tailwindcss/cli"]);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { __private } from "../css";
|
|
3
|
+
|
|
4
|
+
describe("CSS Tailwind command resolution", () => {
|
|
5
|
+
test("uses process.execPath when it is Bun", () => {
|
|
6
|
+
expect(
|
|
7
|
+
__private.getTailwindCommand(["@tailwindcss/cli"], "C:\\tools\\bun.exe"),
|
|
8
|
+
).toEqual(["C:\\tools\\bun.exe", "x", "@tailwindcss/cli"]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("does not treat standalone mandu.exe as Bun", () => {
|
|
12
|
+
expect(
|
|
13
|
+
__private.getTailwindCommand(
|
|
14
|
+
["@tailwindcss/cli"],
|
|
15
|
+
"C:\\Users\\User\\AppData\\Local\\Mandu\\bin\\mandu.exe",
|
|
16
|
+
() => "C:\\Users\\User\\.bun\\bin\\bun.exe",
|
|
17
|
+
),
|
|
18
|
+
).toEqual(["C:\\Users\\User\\.bun\\bin\\bun.exe", "x", "@tailwindcss/cli"]);
|
|
19
|
+
});
|
|
20
|
+
});
|
package/src/bundler/analyzer.ts
CHANGED
|
@@ -341,26 +341,26 @@ export async function analyzeBundle(
|
|
|
341
341
|
deps: entry.dependencies ?? [],
|
|
342
342
|
});
|
|
343
343
|
}
|
|
344
|
-
for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) {
|
|
345
|
-
// Avoid double-counting: if a per-island chunk shares its route id with
|
|
346
|
-
// a route-level bundle, we prefer the island entry (finer granularity).
|
|
347
|
-
const existing = islandSources.findIndex((s) => s.name === islandName);
|
|
344
|
+
for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) {
|
|
345
|
+
// Avoid double-counting: if a per-island chunk shares its route id with
|
|
346
|
+
// a route-level bundle, we prefer the island entry (finer granularity).
|
|
347
|
+
const existing = islandSources.findIndex((s) => s.name === islandName);
|
|
348
348
|
if (existing !== -1) islandSources.splice(existing, 1);
|
|
349
349
|
islandSources.push({
|
|
350
350
|
name: islandName,
|
|
351
351
|
url: entry.js,
|
|
352
352
|
priority: entry.priority,
|
|
353
|
-
deps: [],
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
for (const [partialName, entry] of Object.entries(manifest.partials ?? {})) {
|
|
357
|
-
islandSources.push({
|
|
358
|
-
name: `partial:${partialName}`,
|
|
359
|
-
url: entry.js,
|
|
360
|
-
priority: entry.priority,
|
|
361
|
-
deps: [],
|
|
362
|
-
});
|
|
363
|
-
}
|
|
353
|
+
deps: [],
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
for (const [partialName, entry] of Object.entries(manifest.partials ?? {})) {
|
|
357
|
+
islandSources.push({
|
|
358
|
+
name: `partial:${partialName}`,
|
|
359
|
+
url: entry.js,
|
|
360
|
+
priority: entry.priority,
|
|
361
|
+
deps: [],
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
364
|
|
|
365
365
|
const islands: AnalyzeIsland[] = [];
|
|
366
366
|
for (const src of islandSources) {
|
|
@@ -219,7 +219,7 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
219
219
|
}
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
-
test("rewrites route-component clientModule to the real client import before bundling", async () => {
|
|
222
|
+
test("rewrites route-component clientModule to the real client import before bundling", async () => {
|
|
223
223
|
const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-client-import-"));
|
|
224
224
|
try {
|
|
225
225
|
await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
|
|
@@ -237,16 +237,16 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
237
237
|
"}\n",
|
|
238
238
|
"utf-8",
|
|
239
239
|
);
|
|
240
|
-
await writeFile(
|
|
241
|
-
path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
|
|
242
|
-
'"use client";\n' +
|
|
243
|
-
'import { useState } from "react";\n' +
|
|
244
|
-
"export default function LoginPage() {\n" +
|
|
245
|
-
' const [email, setEmail] = useState("");\n' +
|
|
246
|
-
' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
|
|
247
|
-
"}\n",
|
|
248
|
-
"utf-8",
|
|
249
|
-
);
|
|
240
|
+
await writeFile(
|
|
241
|
+
path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
|
|
242
|
+
'"use client";\n' +
|
|
243
|
+
'import { useState } from "react";\n' +
|
|
244
|
+
"export default function LoginPage() {\n" +
|
|
245
|
+
' const [email, setEmail] = useState("");\n' +
|
|
246
|
+
' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
|
|
247
|
+
"}\n",
|
|
248
|
+
"utf-8",
|
|
249
|
+
);
|
|
250
250
|
|
|
251
251
|
const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-client-import");
|
|
252
252
|
expect(routeClientResult.success).toBe(true);
|
|
@@ -263,10 +263,61 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
263
263
|
expect(parseResult.success).toBe(true);
|
|
264
264
|
} finally {
|
|
265
265
|
await rm(routeClientRoot, { recursive: true, force: true });
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test("
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("bundles route-level named client exports without requiring a default export", async () => {
|
|
270
|
+
const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-named-client-import-"));
|
|
271
|
+
try {
|
|
272
|
+
await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
|
|
273
|
+
await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
|
|
274
|
+
await writeFile(
|
|
275
|
+
path.join(routeClientRoot, "package.json"),
|
|
276
|
+
JSON.stringify({ name: "mandu-route-named-client-import-test", type: "module" }, null, 2),
|
|
277
|
+
"utf-8",
|
|
278
|
+
);
|
|
279
|
+
await writeFile(
|
|
280
|
+
path.join(routeClientRoot, "app", "login", "page.tsx"),
|
|
281
|
+
'import { LoginPage } from "@/client/pages/login/LoginPage.client";\n' +
|
|
282
|
+
"export default function Page() {\n" +
|
|
283
|
+
" return <LoginPage />;\n" +
|
|
284
|
+
"}\n",
|
|
285
|
+
"utf-8",
|
|
286
|
+
);
|
|
287
|
+
await writeFile(
|
|
288
|
+
path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
|
|
289
|
+
'"use client";\n' +
|
|
290
|
+
'import { useState } from "react";\n' +
|
|
291
|
+
"export function LoginPage() {\n" +
|
|
292
|
+
' const [email, setEmail] = useState("");\n' +
|
|
293
|
+
' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
|
|
294
|
+
"}\n",
|
|
295
|
+
"utf-8",
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-named-client-import");
|
|
299
|
+
if (!routeClientResult.success) {
|
|
300
|
+
console.error("[named route client] errors:", routeClientResult.errors);
|
|
301
|
+
}
|
|
302
|
+
expect(routeClientResult.success).toBe(true);
|
|
303
|
+
const bundlePath = path.join(routeClientRoot, ".mandu", "client", "login.island.js");
|
|
304
|
+
expect(await Bun.file(bundlePath).exists()).toBe(true);
|
|
305
|
+
|
|
306
|
+
const bundleSource = await readFile(bundlePath, "utf-8");
|
|
307
|
+
expect(bundleSource).toContain("LoginPage");
|
|
308
|
+
expect(bundleSource).not.toContain("Client islands cannot use server-side modules");
|
|
309
|
+
const parseResult = await Bun.build({
|
|
310
|
+
entrypoints: [bundlePath],
|
|
311
|
+
target: "browser",
|
|
312
|
+
external: ["react", "react-dom", "react-dom/client", "react/jsx-dev-runtime"],
|
|
313
|
+
});
|
|
314
|
+
expect(parseResult.success).toBe(true);
|
|
315
|
+
} finally {
|
|
316
|
+
await rm(routeClientRoot, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("fails when hydration is enabled but no clientModule can be resolved", async () => {
|
|
270
321
|
const missingRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-hydration-no-client-"));
|
|
271
322
|
try {
|
|
272
323
|
await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
|
package/src/bundler/build.ts
CHANGED
|
@@ -100,16 +100,16 @@ function shouldSplitChunks(options: BundlerOptions): boolean {
|
|
|
100
100
|
return options.splitting ?? (resolveBundlerMode(options) === "production");
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function nodeEnvDefine(options: BundlerOptions): string {
|
|
104
|
-
return JSON.stringify(resolveBundlerMode(options));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const FAST_REFRESH_SELF_ALIAS_PATTERN =
|
|
108
|
-
/^var\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*\1;\s*\r?\n(?=\$RefreshReg\$\(\1,\s*["'][^"']*:default["']\);)/gm;
|
|
109
|
-
|
|
110
|
-
function resolveClientOutDir(rootDir: string, outDir?: string): string {
|
|
111
|
-
const defaultOutDir = path.join(rootDir, ".mandu/client");
|
|
112
|
-
if (!outDir) return defaultOutDir;
|
|
103
|
+
function nodeEnvDefine(options: BundlerOptions): string {
|
|
104
|
+
return JSON.stringify(resolveBundlerMode(options));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const FAST_REFRESH_SELF_ALIAS_PATTERN =
|
|
108
|
+
/^var\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*\1;\s*\r?\n(?=\$RefreshReg\$\(\1,\s*["'][^"']*:default["']\);)/gm;
|
|
109
|
+
|
|
110
|
+
function resolveClientOutDir(rootDir: string, outDir?: string): string {
|
|
111
|
+
const defaultOutDir = path.join(rootDir, ".mandu/client");
|
|
112
|
+
if (!outDir) return defaultOutDir;
|
|
113
113
|
|
|
114
114
|
const resolvedOutDir = path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir);
|
|
115
115
|
if (path.normalize(resolvedOutDir) === path.normalize(path.join(rootDir, ".mandu"))) {
|
|
@@ -296,9 +296,9 @@ export async function collectCompilerLintTargets(
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/** Build a single per-island bundle. */
|
|
299
|
-
async function buildPerIslandBundle(
|
|
300
|
-
entry: IslandFileEntry, outDir: string, options: BundlerOptions
|
|
301
|
-
): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
|
|
299
|
+
async function buildPerIslandBundle(
|
|
300
|
+
entry: IslandFileEntry, outDir: string, options: BundlerOptions
|
|
301
|
+
): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
|
|
302
302
|
const entryPath = path.join(outDir, `_entry_island_${entry.name}.js`);
|
|
303
303
|
const outputName = `${entry.name}.island.js`;
|
|
304
304
|
// Phase 7.1 B-1/B-4: wire Bun's native React Fast Refresh transform +
|
|
@@ -320,13 +320,13 @@ async function buildPerIslandBundle(
|
|
|
320
320
|
define: { "process.env.NODE_ENV": nodeEnvDefine(options), ...options.define },
|
|
321
321
|
});
|
|
322
322
|
await fs.unlink(entryPath).catch(() => {});
|
|
323
|
-
if (!result.success) {
|
|
324
|
-
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
325
|
-
throw new Error(`Island build failed for '${entry.name}' (source: ${entry.filePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types in this island file.`);
|
|
326
|
-
}
|
|
327
|
-
await sanitizeGeneratedClientBundle(path.join(outDir, outputName), isDev);
|
|
328
|
-
return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
|
|
329
|
-
} catch (error) {
|
|
323
|
+
if (!result.success) {
|
|
324
|
+
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
325
|
+
throw new Error(`Island build failed for '${entry.name}' (source: ${entry.filePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types in this island file.`);
|
|
326
|
+
}
|
|
327
|
+
await sanitizeGeneratedClientBundle(path.join(outDir, outputName), isDev);
|
|
328
|
+
return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
|
|
329
|
+
} catch (error) {
|
|
330
330
|
await fs.unlink(entryPath).catch(() => {});
|
|
331
331
|
throw error;
|
|
332
332
|
}
|
|
@@ -370,11 +370,11 @@ async function buildPartialBundle(
|
|
|
370
370
|
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
371
371
|
throw new Error(`Partial build failed for '${entry.name}' (source: ${entry.filePath}):\n${grouped}\n Hint: Export a Mandu partial from this file with \`partial({ component })\`.`);
|
|
372
372
|
}
|
|
373
|
-
|
|
374
|
-
const outputPath = path.join(outDir, outputName);
|
|
375
|
-
const sanitizedContent = await sanitizeGeneratedClientBundle(outputPath, isDev);
|
|
376
|
-
const outputFile = Bun.file(outputPath);
|
|
377
|
-
const gzipped = Bun.gzipSync(Buffer.from(sanitizedContent));
|
|
373
|
+
|
|
374
|
+
const outputPath = path.join(outDir, outputName);
|
|
375
|
+
const sanitizedContent = await sanitizeGeneratedClientBundle(outputPath, isDev);
|
|
376
|
+
const outputFile = Bun.file(outputPath);
|
|
377
|
+
const gzipped = Bun.gzipSync(Buffer.from(sanitizedContent));
|
|
378
378
|
|
|
379
379
|
return {
|
|
380
380
|
name: entry.name,
|
|
@@ -1443,18 +1443,61 @@ async function buildRouterRuntime(
|
|
|
1443
1443
|
* - Runtime이 dynamic import로 로드
|
|
1444
1444
|
* - 등록/초기화 코드 없음
|
|
1445
1445
|
*/
|
|
1446
|
-
function generateIslandEntry(routeId: string, clientModulePath: string): string {
|
|
1447
|
-
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
1448
|
-
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1446
|
+
function generateIslandEntry(routeId: string, clientModulePath: string, exportName?: string): string {
|
|
1447
|
+
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
1448
|
+
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
1449
|
+
const normalizedExportName = exportName && exportName !== "default" ? exportName : undefined;
|
|
1450
|
+
const candidates = [
|
|
1451
|
+
normalizedExportName,
|
|
1452
|
+
inferClientExportNameFromPath(clientModulePath),
|
|
1453
|
+
inferClientExportNameFromRouteId(routeId),
|
|
1454
|
+
].filter((candidate, index, values): candidate is string =>
|
|
1455
|
+
!!candidate && values.indexOf(candidate) === index
|
|
1456
|
+
);
|
|
1457
|
+
const importSpecifier = JSON.stringify(normalizedPath);
|
|
1458
|
+
const routeLabel = JSON.stringify(routeId);
|
|
1459
|
+
const commentRouteId = routeId.replace(/\*\//g, "* /");
|
|
1460
|
+
return `
|
|
1461
|
+
/**
|
|
1462
|
+
* Mandu Island: ${commentRouteId} (Generated)
|
|
1463
|
+
* Pure export - no side effects
|
|
1464
|
+
*/
|
|
1465
|
+
import * as islandModule from ${importSpecifier};
|
|
1466
|
+
|
|
1467
|
+
const candidateExportNames = ${JSON.stringify(candidates)};
|
|
1468
|
+
|
|
1469
|
+
function resolveIslandExport(mod) {
|
|
1470
|
+
if (mod.default) return mod.default;
|
|
1471
|
+
for (const name of candidateExportNames) {
|
|
1472
|
+
if (mod[name]) return mod[name];
|
|
1473
|
+
}
|
|
1474
|
+
const runtimeExports = Object.keys(mod).filter((name) => name !== "__esModule");
|
|
1475
|
+
if (runtimeExports.length === 1) return mod[runtimeExports[0]];
|
|
1476
|
+
throw new Error(
|
|
1477
|
+
"[Mandu Island] " + ${routeLabel} + " must export a default component" +
|
|
1478
|
+
(candidateExportNames.length > 0 ? " or one of: " + candidateExportNames.join(", ") : "")
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const island = resolveIslandExport(islandModule);
|
|
1483
|
+
export default island;
|
|
1484
|
+
`;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function inferClientExportNameFromPath(clientModulePath: string): string | null {
|
|
1488
|
+
const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
|
|
1489
|
+
const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
|
|
1490
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix) ? withoutClientSuffix : null;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function inferClientExportNameFromRouteId(routeId: string): string | null {
|
|
1494
|
+
const pascal = routeId
|
|
1495
|
+
.split(/[^A-Za-z0-9]+/)
|
|
1496
|
+
.filter(Boolean)
|
|
1497
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1498
|
+
.join("");
|
|
1499
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : null;
|
|
1500
|
+
}
|
|
1458
1501
|
|
|
1459
1502
|
function generatePartialEntry(partialId: string, partialModulePath: string): string {
|
|
1460
1503
|
const normalizedPath = partialModulePath.replace(/\\/g, "/");
|
|
@@ -1911,7 +1954,7 @@ async function buildIsland(
|
|
|
1911
1954
|
const isDev = isDevelopmentBuild(options);
|
|
1912
1955
|
try {
|
|
1913
1956
|
// 엔트리 래퍼 생성
|
|
1914
|
-
await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath));
|
|
1957
|
+
await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath, route.clientExportName));
|
|
1915
1958
|
|
|
1916
1959
|
// 빌드
|
|
1917
1960
|
// splitting 옵션: true면 공통 코드를 별도 청크로 추출
|
|
@@ -1963,9 +2006,9 @@ async function buildIsland(
|
|
|
1963
2006
|
actualOutputName = outputName;
|
|
1964
2007
|
}
|
|
1965
2008
|
|
|
1966
|
-
const outputFile = Bun.file(actualOutputPath);
|
|
1967
|
-
const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
|
|
1968
|
-
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
2009
|
+
const outputFile = Bun.file(actualOutputPath);
|
|
2010
|
+
const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
|
|
2011
|
+
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
1969
2012
|
|
|
1970
2013
|
return {
|
|
1971
2014
|
routeId: route.id,
|
|
@@ -1977,53 +2020,53 @@ async function buildIsland(
|
|
|
1977
2020
|
} catch (error) {
|
|
1978
2021
|
await fs.unlink(entryPath).catch(() => {});
|
|
1979
2022
|
throw error;
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
1982
|
-
|
|
1983
|
-
async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
|
|
1984
|
-
const source = await Bun.file(outputPath).text();
|
|
1985
|
-
if (!isDev) return source;
|
|
1986
|
-
|
|
1987
|
-
const sanitized = removeFastRefreshSelfAliases(source);
|
|
1988
|
-
if (sanitized === source) return source;
|
|
1989
|
-
|
|
1990
|
-
await Bun.write(outputPath, sanitized);
|
|
1991
|
-
await validateGeneratedClientBundle(outputPath);
|
|
1992
|
-
return sanitized;
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
function removeFastRefreshSelfAliases(source: string): string {
|
|
1996
|
-
return source.replace(FAST_REFRESH_SELF_ALIAS_PATTERN, (line, localName: string, offset: number) => {
|
|
1997
|
-
const priorSource = source.slice(0, offset);
|
|
1998
|
-
const declarationPattern = new RegExp(`(?:const|let|class|function)\\s+${escapeRegExp(localName)}\\b`);
|
|
1999
|
-
return declarationPattern.test(priorSource) ? "" : line;
|
|
2000
|
-
});
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
async function validateGeneratedClientBundle(outputPath: string): Promise<void> {
|
|
2004
|
-
const tempOutDir = await fs.mkdtemp(path.join(path.dirname(outputPath), ".validate-"));
|
|
2005
|
-
try {
|
|
2006
|
-
const result = await safeBuild({
|
|
2007
|
-
entrypoints: [outputPath],
|
|
2008
|
-
outdir: tempOutDir,
|
|
2009
|
-
target: "browser",
|
|
2010
|
-
external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
2011
|
-
});
|
|
2012
|
-
if (result.success) return;
|
|
2013
|
-
|
|
2014
|
-
const grouped = result.logs.map((log) => ` - ${log.message}`).join("\n");
|
|
2015
|
-
throw new Error(`Generated client bundle failed syntax validation (source: ${outputPath}):\n${grouped}`);
|
|
2016
|
-
} finally {
|
|
2017
|
-
await fs.rm(tempOutDir, { recursive: true, force: true }).catch(() => {});
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
function escapeRegExp(value: string): string {
|
|
2022
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
/**
|
|
2026
|
-
* 번들 매니페스트 생성
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
|
|
2027
|
+
const source = await Bun.file(outputPath).text();
|
|
2028
|
+
if (!isDev) return source;
|
|
2029
|
+
|
|
2030
|
+
const sanitized = removeFastRefreshSelfAliases(source);
|
|
2031
|
+
if (sanitized === source) return source;
|
|
2032
|
+
|
|
2033
|
+
await Bun.write(outputPath, sanitized);
|
|
2034
|
+
await validateGeneratedClientBundle(outputPath);
|
|
2035
|
+
return sanitized;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
function removeFastRefreshSelfAliases(source: string): string {
|
|
2039
|
+
return source.replace(FAST_REFRESH_SELF_ALIAS_PATTERN, (line, localName: string, offset: number) => {
|
|
2040
|
+
const priorSource = source.slice(0, offset);
|
|
2041
|
+
const declarationPattern = new RegExp(`(?:const|let|class|function)\\s+${escapeRegExp(localName)}\\b`);
|
|
2042
|
+
return declarationPattern.test(priorSource) ? "" : line;
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
async function validateGeneratedClientBundle(outputPath: string): Promise<void> {
|
|
2047
|
+
const tempOutDir = await fs.mkdtemp(path.join(path.dirname(outputPath), ".validate-"));
|
|
2048
|
+
try {
|
|
2049
|
+
const result = await safeBuild({
|
|
2050
|
+
entrypoints: [outputPath],
|
|
2051
|
+
outdir: tempOutDir,
|
|
2052
|
+
target: "browser",
|
|
2053
|
+
external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
2054
|
+
});
|
|
2055
|
+
if (result.success) return;
|
|
2056
|
+
|
|
2057
|
+
const grouped = result.logs.map((log) => ` - ${log.message}`).join("\n");
|
|
2058
|
+
throw new Error(`Generated client bundle failed syntax validation (source: ${outputPath}):\n${grouped}`);
|
|
2059
|
+
} finally {
|
|
2060
|
+
await fs.rm(tempOutDir, { recursive: true, force: true }).catch(() => {});
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function escapeRegExp(value: string): string {
|
|
2065
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* 번들 매니페스트 생성
|
|
2027
2070
|
*/
|
|
2028
2071
|
function createBundleManifest(
|
|
2029
2072
|
outputs: BundleOutput[],
|
|
@@ -2559,25 +2602,24 @@ export async function buildClientBundles(
|
|
|
2559
2602
|
try {
|
|
2560
2603
|
return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
|
|
2561
2604
|
} catch (error) {
|
|
2562
|
-
return { ok: false as const, route, error:
|
|
2605
|
+
return { ok: false as const, route, error: formatBundlerException(error) };
|
|
2563
2606
|
}
|
|
2564
2607
|
}),
|
|
2565
2608
|
);
|
|
2566
2609
|
for (const r of fullIslandResults) {
|
|
2567
|
-
if (r.ok) {
|
|
2568
|
-
outputs.push(r.result);
|
|
2569
|
-
} else {
|
|
2570
|
-
const errorStr = r.error;
|
|
2571
|
-
if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
|
|
2572
|
-
const clientModule = r.route.clientModule || "";
|
|
2573
|
-
errors.push(
|
|
2574
|
-
`[${r.route.id}] ${errorStr}\n` +
|
|
2575
|
-
`
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
}
|
|
2610
|
+
if (r.ok) {
|
|
2611
|
+
outputs.push(r.result);
|
|
2612
|
+
} else {
|
|
2613
|
+
const errorStr = r.error;
|
|
2614
|
+
if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
|
|
2615
|
+
const clientModule = r.route.clientModule || "";
|
|
2616
|
+
errors.push(
|
|
2617
|
+
`[${r.route.id}] ${errorStr}\n` +
|
|
2618
|
+
` Hint: Check import paths and browser-compatible exports for this island. File: ${clientModule}`,
|
|
2619
|
+
);
|
|
2620
|
+
} else {
|
|
2621
|
+
errors.push(`[${r.route.id}] ${errorStr}`);
|
|
2622
|
+
}
|
|
2581
2623
|
}
|
|
2582
2624
|
}
|
|
2583
2625
|
|
|
@@ -2678,6 +2720,12 @@ export function printBundleStats(result: BundleResult): void {
|
|
|
2678
2720
|
const partialCount = Object.keys(result.manifest.partials ?? {}).length;
|
|
2679
2721
|
if (result.outputs.length === 0 && partialCount === 0) {
|
|
2680
2722
|
console.log("No islands or partials to bundle (hydration: none or no client entry)");
|
|
2723
|
+
if (result.errors.length > 0) {
|
|
2724
|
+
console.log("\n⚠️ Errors:");
|
|
2725
|
+
for (const error of result.errors) {
|
|
2726
|
+
console.log(` ${error}`);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2681
2729
|
return;
|
|
2682
2730
|
}
|
|
2683
2731
|
|
|
@@ -2707,3 +2755,17 @@ export function printBundleStats(result: BundleResult): void {
|
|
|
2707
2755
|
|
|
2708
2756
|
console.log("");
|
|
2709
2757
|
}
|
|
2758
|
+
|
|
2759
|
+
function formatBundlerException(error: unknown): string {
|
|
2760
|
+
if (error instanceof AggregateError) {
|
|
2761
|
+
const parts = [String(error)];
|
|
2762
|
+
for (const nested of error.errors) {
|
|
2763
|
+
parts.push(` - ${formatBundlerException(nested).replace(/\n/g, "\n ")}`);
|
|
2764
|
+
}
|
|
2765
|
+
return parts.join("\n");
|
|
2766
|
+
}
|
|
2767
|
+
if (error instanceof Error) {
|
|
2768
|
+
return error.stack ?? error.message;
|
|
2769
|
+
}
|
|
2770
|
+
return String(error);
|
|
2771
|
+
}
|