@mandujs/core 0.54.12 → 0.54.14
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 +36 -16
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +20 -20
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/build.test.ts +136 -48
- package/src/bundler/build.ts +193 -122
- 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 +84 -41
- package/src/router/client-entry.ts +156 -89
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +37 -29
- package/src/router/fs-scanner.ts +81 -33
- 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 +164 -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 +208 -106
- package/src/runtime/rate-limit.ts +231 -0
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/scheduler-lifecycle.ts +64 -0
- package/src/runtime/server.ts +148 -353
- 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
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, "/");
|
|
@@ -1886,32 +1929,41 @@ async function buildVendorShims(
|
|
|
1886
1929
|
};
|
|
1887
1930
|
}
|
|
1888
1931
|
|
|
1889
|
-
function vendorShimFailureHint(shimName: string): string {
|
|
1890
|
-
if (shimName.includes("react-refresh")) {
|
|
1891
|
-
return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
|
|
1892
|
-
}
|
|
1893
|
-
return "Hint: check the import paths and ensure the vendor package is installed.";
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1932
|
+
function vendorShimFailureHint(shimName: string): string {
|
|
1933
|
+
if (shimName.includes("react-refresh")) {
|
|
1934
|
+
return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
|
|
1935
|
+
}
|
|
1936
|
+
return "Hint: check the import paths and ensure the vendor package is installed.";
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
function routeIdToAssetStem(routeId: string): string {
|
|
1940
|
+
const safe = routeId.replace(/[<>:"/\\|?*\x00-\x1F]/g, (ch) =>
|
|
1941
|
+
`_${ch.codePointAt(0)!.toString(16)}_`
|
|
1942
|
+
);
|
|
1943
|
+
return safe.replace(/[. ]+$/g, "") || "route";
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
/**
|
|
1947
|
+
* 단일 Island 번들 빌드
|
|
1948
|
+
*/
|
|
1899
1949
|
async function buildIsland(
|
|
1900
1950
|
route: RouteSpec,
|
|
1901
1951
|
rootDir: string,
|
|
1902
1952
|
outDir: string,
|
|
1903
1953
|
options: BundlerOptions
|
|
1904
|
-
): Promise<BundleOutput> {
|
|
1905
|
-
const clientModulePath = path.join(rootDir, route.clientModule!);
|
|
1906
|
-
const
|
|
1907
|
-
const
|
|
1954
|
+
): Promise<BundleOutput> {
|
|
1955
|
+
const clientModulePath = path.join(rootDir, route.clientModule!);
|
|
1956
|
+
const assetStem = routeIdToAssetStem(route.id);
|
|
1957
|
+
const entryStem = `_entry_${assetStem}`;
|
|
1958
|
+
const entryPath = path.join(outDir, `${entryStem}.js`);
|
|
1959
|
+
const outputName = `${assetStem}.island.js`;
|
|
1908
1960
|
|
|
1909
1961
|
// Phase 7.1 B-1/B-4: wire native Fast Refresh transform + Mandu's
|
|
1910
1962
|
// boundary injection plugin. Dev-only; prod bundles remain clean.
|
|
1911
1963
|
const isDev = isDevelopmentBuild(options);
|
|
1912
1964
|
try {
|
|
1913
1965
|
// 엔트리 래퍼 생성
|
|
1914
|
-
await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath));
|
|
1966
|
+
await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath, route.clientExportName));
|
|
1915
1967
|
|
|
1916
1968
|
// 빌드
|
|
1917
1969
|
// splitting 옵션: true면 공통 코드를 별도 청크로 추출
|
|
@@ -1945,11 +1997,11 @@ async function buildIsland(
|
|
|
1945
1997
|
let actualOutputPath: string;
|
|
1946
1998
|
let actualOutputName: string;
|
|
1947
1999
|
|
|
1948
|
-
if (options.splitting && result.outputs.length > 0) {
|
|
1949
|
-
// splitting 모드: 결과에서 엔트리 파일 찾기
|
|
1950
|
-
const entryOutput = result.outputs.find(
|
|
1951
|
-
(o) => o.kind === "entry-point" || o.path.includes(
|
|
1952
|
-
);
|
|
2000
|
+
if (options.splitting && result.outputs.length > 0) {
|
|
2001
|
+
// splitting 모드: 결과에서 엔트리 파일 찾기
|
|
2002
|
+
const entryOutput = result.outputs.find(
|
|
2003
|
+
(o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem)
|
|
2004
|
+
);
|
|
1953
2005
|
if (entryOutput) {
|
|
1954
2006
|
actualOutputPath = entryOutput.path;
|
|
1955
2007
|
actualOutputName = path.basename(entryOutput.path);
|
|
@@ -1963,9 +2015,9 @@ async function buildIsland(
|
|
|
1963
2015
|
actualOutputName = outputName;
|
|
1964
2016
|
}
|
|
1965
2017
|
|
|
1966
|
-
const outputFile = Bun.file(actualOutputPath);
|
|
1967
|
-
const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
|
|
1968
|
-
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
2018
|
+
const outputFile = Bun.file(actualOutputPath);
|
|
2019
|
+
const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
|
|
2020
|
+
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
1969
2021
|
|
|
1970
2022
|
return {
|
|
1971
2023
|
routeId: route.id,
|
|
@@ -1977,53 +2029,53 @@ async function buildIsland(
|
|
|
1977
2029
|
} catch (error) {
|
|
1978
2030
|
await fs.unlink(entryPath).catch(() => {});
|
|
1979
2031
|
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
|
-
* 번들 매니페스트 생성
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
|
|
2036
|
+
const source = await Bun.file(outputPath).text();
|
|
2037
|
+
if (!isDev) return source;
|
|
2038
|
+
|
|
2039
|
+
const sanitized = removeFastRefreshSelfAliases(source);
|
|
2040
|
+
if (sanitized === source) return source;
|
|
2041
|
+
|
|
2042
|
+
await Bun.write(outputPath, sanitized);
|
|
2043
|
+
await validateGeneratedClientBundle(outputPath);
|
|
2044
|
+
return sanitized;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function removeFastRefreshSelfAliases(source: string): string {
|
|
2048
|
+
return source.replace(FAST_REFRESH_SELF_ALIAS_PATTERN, (line, localName: string, offset: number) => {
|
|
2049
|
+
const priorSource = source.slice(0, offset);
|
|
2050
|
+
const declarationPattern = new RegExp(`(?:const|let|class|function)\\s+${escapeRegExp(localName)}\\b`);
|
|
2051
|
+
return declarationPattern.test(priorSource) ? "" : line;
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
async function validateGeneratedClientBundle(outputPath: string): Promise<void> {
|
|
2056
|
+
const tempOutDir = await fs.mkdtemp(path.join(path.dirname(outputPath), ".validate-"));
|
|
2057
|
+
try {
|
|
2058
|
+
const result = await safeBuild({
|
|
2059
|
+
entrypoints: [outputPath],
|
|
2060
|
+
outdir: tempOutDir,
|
|
2061
|
+
target: "browser",
|
|
2062
|
+
external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
2063
|
+
});
|
|
2064
|
+
if (result.success) return;
|
|
2065
|
+
|
|
2066
|
+
const grouped = result.logs.map((log) => ` - ${log.message}`).join("\n");
|
|
2067
|
+
throw new Error(`Generated client bundle failed syntax validation (source: ${outputPath}):\n${grouped}`);
|
|
2068
|
+
} finally {
|
|
2069
|
+
await fs.rm(tempOutDir, { recursive: true, force: true }).catch(() => {});
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function escapeRegExp(value: string): string {
|
|
2074
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* 번들 매니페스트 생성
|
|
2027
2079
|
*/
|
|
2028
2080
|
function createBundleManifest(
|
|
2029
2081
|
outputs: BundleOutput[],
|
|
@@ -2559,25 +2611,24 @@ export async function buildClientBundles(
|
|
|
2559
2611
|
try {
|
|
2560
2612
|
return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
|
|
2561
2613
|
} catch (error) {
|
|
2562
|
-
return { ok: false as const, route, error:
|
|
2614
|
+
return { ok: false as const, route, error: formatBundlerException(error) };
|
|
2563
2615
|
}
|
|
2564
2616
|
}),
|
|
2565
2617
|
);
|
|
2566
2618
|
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
|
-
}
|
|
2619
|
+
if (r.ok) {
|
|
2620
|
+
outputs.push(r.result);
|
|
2621
|
+
} else {
|
|
2622
|
+
const errorStr = r.error;
|
|
2623
|
+
if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
|
|
2624
|
+
const clientModule = r.route.clientModule || "";
|
|
2625
|
+
errors.push(
|
|
2626
|
+
`[${r.route.id}] ${errorStr}\n` +
|
|
2627
|
+
` Hint: Check import paths and browser-compatible exports for this island. File: ${clientModule}`,
|
|
2628
|
+
);
|
|
2629
|
+
} else {
|
|
2630
|
+
errors.push(`[${r.route.id}] ${errorStr}`);
|
|
2631
|
+
}
|
|
2581
2632
|
}
|
|
2582
2633
|
}
|
|
2583
2634
|
|
|
@@ -2678,6 +2729,12 @@ export function printBundleStats(result: BundleResult): void {
|
|
|
2678
2729
|
const partialCount = Object.keys(result.manifest.partials ?? {}).length;
|
|
2679
2730
|
if (result.outputs.length === 0 && partialCount === 0) {
|
|
2680
2731
|
console.log("No islands or partials to bundle (hydration: none or no client entry)");
|
|
2732
|
+
if (result.errors.length > 0) {
|
|
2733
|
+
console.log("\n⚠️ Errors:");
|
|
2734
|
+
for (const error of result.errors) {
|
|
2735
|
+
console.log(` ${error}`);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2681
2738
|
return;
|
|
2682
2739
|
}
|
|
2683
2740
|
|
|
@@ -2707,3 +2764,17 @@ export function printBundleStats(result: BundleResult): void {
|
|
|
2707
2764
|
|
|
2708
2765
|
console.log("");
|
|
2709
2766
|
}
|
|
2767
|
+
|
|
2768
|
+
function formatBundlerException(error: unknown): string {
|
|
2769
|
+
if (error instanceof AggregateError) {
|
|
2770
|
+
const parts = [String(error)];
|
|
2771
|
+
for (const nested of error.errors) {
|
|
2772
|
+
parts.push(` - ${formatBundlerException(nested).replace(/\n/g, "\n ")}`);
|
|
2773
|
+
}
|
|
2774
|
+
return parts.join("\n");
|
|
2775
|
+
}
|
|
2776
|
+
if (error instanceof Error) {
|
|
2777
|
+
return error.stack ?? error.message;
|
|
2778
|
+
}
|
|
2779
|
+
return String(error);
|
|
2780
|
+
}
|
package/src/bundler/css.ts
CHANGED
|
@@ -18,38 +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
|
-
* `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
|
-
}
|
|
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
|
+
}
|
|
53
53
|
|
|
54
54
|
// ========== Types ==========
|
|
55
55
|
|
|
@@ -344,13 +344,13 @@ export function getCSSServerPath(): string {
|
|
|
344
344
|
/**
|
|
345
345
|
* CSS 링크 태그 생성
|
|
346
346
|
*/
|
|
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
|
-
};
|
|
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,8 +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`, `partials[].js`,
|
|
32
|
-
* `importMap.imports[*]`):
|
|
31
|
+
* `bundles[].js`, `bundles[].css`, `islands[].js`, `partials[].js`,
|
|
32
|
+
* `importMap.imports[*]`):
|
|
33
33
|
*
|
|
34
34
|
* ALLOW: absolute paths rooted at `/.mandu/client/` ending in `.js` or `.css`.
|
|
35
35
|
* The bundler itself only ever emits this shape.
|
|
@@ -162,20 +162,20 @@ const BundleEntrySchema = z.object({
|
|
|
162
162
|
priority: PrioritySchema,
|
|
163
163
|
});
|
|
164
164
|
|
|
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"),
|
|
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"),
|
|
179
179
|
});
|
|
180
180
|
|
|
181
181
|
const SharedSchema = z.object({
|
|
@@ -225,11 +225,11 @@ export const BundleManifestSchema = z
|
|
|
225
225
|
.object({
|
|
226
226
|
version: z.number().int().min(1),
|
|
227
227
|
buildTime: z.string().min(1),
|
|
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,
|
|
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,
|
|
233
233
|
importMap: ImportMapSchema.optional(),
|
|
234
234
|
})
|
|
235
235
|
.strict();
|
|
@@ -80,19 +80,19 @@ describe("defaultAllowImporter", () => {
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
describe("DEFAULT_BLOCK_FILTER", () => {
|
|
83
|
-
test("matches __generated__ specifiers", () => {
|
|
84
|
-
expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
85
|
-
expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
86
|
-
});
|
|
87
|
-
test("matches direct .mandu/generated relative specifiers", () => {
|
|
88
|
-
expect("../.mandu/generated/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
89
|
-
expect("../../../.mandu/generated/server/repos/party.repo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
90
|
-
});
|
|
91
|
-
test("does NOT match look-alikes without double underscores", () => {
|
|
92
|
-
expect("./generated/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
93
|
-
expect("./src/generate/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
94
|
-
});
|
|
95
|
-
});
|
|
83
|
+
test("matches __generated__ specifiers", () => {
|
|
84
|
+
expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
85
|
+
expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
86
|
+
});
|
|
87
|
+
test("matches direct .mandu/generated relative specifiers", () => {
|
|
88
|
+
expect("../.mandu/generated/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
89
|
+
expect("../../../.mandu/generated/server/repos/party.repo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
90
|
+
});
|
|
91
|
+
test("does NOT match look-alikes without double underscores", () => {
|
|
92
|
+
expect("./generated/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
93
|
+
expect("./src/generate/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
96
|
|
|
97
97
|
describe("defaultBundlerPlugins", () => {
|
|
98
98
|
test("installs block-generated-imports by default", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bun bundler plugin — hard-fail on direct generated-artifact imports.
|
|
2
|
+
* Bun bundler plugin — hard-fail on direct generated-artifact imports.
|
|
3
3
|
*
|
|
4
4
|
* Background
|
|
5
5
|
* ──────────
|
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
* but it only runs when the user (or CI) invokes `mandu guard check`.
|
|
9
9
|
* Autonomous coding agents routinely bypass that step. This plugin closes
|
|
10
10
|
* the gap at the bundler level: every `mandu dev` / `mandu build` pass
|
|
11
|
-
* installs it by default, and any import whose specifier targets
|
|
12
|
-
* `__generated__` or `.mandu/generated` fails the build with a structured,
|
|
13
|
-
* actionable error.
|
|
11
|
+
* installs it by default, and any import whose specifier targets
|
|
12
|
+
* `__generated__` or `.mandu/generated` fails the build with a structured,
|
|
13
|
+
* actionable error.
|
|
14
14
|
*
|
|
15
15
|
* Design
|
|
16
16
|
* ──────
|
|
17
|
-
* - `onResolve({ filter: DEFAULT_BLOCK_FILTER })` — Bun hands us every
|
|
18
|
-
* import whose *specifier* matches the regex, along with the importer's
|
|
19
|
-
* path (`args.importer`). We never return a result; we always throw.
|
|
17
|
+
* - `onResolve({ filter: DEFAULT_BLOCK_FILTER })` — Bun hands us every
|
|
18
|
+
* import whose *specifier* matches the regex, along with the importer's
|
|
19
|
+
* path (`args.importer`). We never return a result; we always throw.
|
|
20
20
|
* - The error is `ForbiddenGeneratedImportError`, a named subclass of
|
|
21
21
|
* `Error`. Tests can `instanceof`-check; Bun surfaces `error.message` in
|
|
22
22
|
* its `result.logs` output for CLI display.
|
|
@@ -91,8 +91,8 @@ export interface BlockGeneratedImportsOptions {
|
|
|
91
91
|
allowImporter?: (importerPath: string) => boolean;
|
|
92
92
|
/**
|
|
93
93
|
* Custom filter regex applied to the import specifier. Defaults to
|
|
94
|
-
* generated-artifact paths. Mandu ships a single default — exposing this
|
|
95
|
-
* for test harnesses that want to narrow or broaden the filter.
|
|
94
|
+
* generated-artifact paths. Mandu ships a single default — exposing this
|
|
95
|
+
* for test harnesses that want to narrow or broaden the filter.
|
|
96
96
|
*/
|
|
97
97
|
filter?: RegExp;
|
|
98
98
|
}
|
|
@@ -115,7 +115,7 @@ export function defaultAllowImporter(importerPath: string): boolean {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Build a `BunPlugin` that blocks direct generated-artifact imports.
|
|
118
|
+
* Build a `BunPlugin` that blocks direct generated-artifact imports.
|
|
119
119
|
*
|
|
120
120
|
* Usage — call from `defaultBundlerPlugins(config)` (see `./index.ts`).
|
|
121
121
|
* Every `safeBuild` / `Bun.build` invocation in Mandu funnels through
|
|
@@ -124,7 +124,7 @@ export function defaultAllowImporter(importerPath: string): boolean {
|
|
|
124
124
|
export function blockGeneratedImports(
|
|
125
125
|
options: BlockGeneratedImportsOptions = {},
|
|
126
126
|
): BunPlugin {
|
|
127
|
-
const filter = options.filter ?? DEFAULT_BLOCK_FILTER;
|
|
127
|
+
const filter = options.filter ?? DEFAULT_BLOCK_FILTER;
|
|
128
128
|
const allowImporter = options.allowImporter ?? defaultAllowImporter;
|
|
129
129
|
|
|
130
130
|
return {
|
|
@@ -152,5 +152,5 @@ export function blockGeneratedImports(
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
/** Exported for unit-test convenience — keep the filter text assertable. */
|
|
156
|
-
export const DEFAULT_BLOCK_FILTER = /__generated__|(?:^|[\/\\])\.mandu[\/\\]generated(?:[\/\\]|$)/;
|
|
155
|
+
/** Exported for unit-test convenience — keep the filter text assertable. */
|
|
156
|
+
export const DEFAULT_BLOCK_FILTER = /__generated__|(?:^|[\/\\])\.mandu[\/\\]generated(?:[\/\\]|$)/;
|