@mandujs/core 0.54.3 → 0.54.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,16 +54,36 @@
54
54
  import { buildClientBundles } from "../build";
55
55
  import type { RoutesManifest } from "../../spec/schema";
56
56
 
57
- const rootDir = process.argv[2];
58
- if (!rootDir) {
59
- console.error("usage: build-runner.ts <rootDir>");
60
- process.exit(2);
61
- }
62
-
63
- const manifest: RoutesManifest = {
64
- version: 1,
65
- routes: [
66
- {
57
+ const rootDir = process.argv[2];
58
+ if (!rootDir) {
59
+ console.error("usage: build-runner.ts <rootDir>");
60
+ process.exit(2);
61
+ }
62
+ const mode = process.argv[3] ?? "default";
63
+
64
+ const manifest: RoutesManifest = mode === "server-page-client-module"
65
+ ? {
66
+ version: 1,
67
+ routes: [
68
+ {
69
+ id: "index",
70
+ kind: "page",
71
+ pattern: "/",
72
+ module: "app/page.tsx",
73
+ componentModule: "app/page.tsx",
74
+ clientModule: "app/page.tsx",
75
+ hydration: {
76
+ strategy: "island",
77
+ priority: "visible",
78
+ preload: false,
79
+ },
80
+ },
81
+ ],
82
+ }
83
+ : {
84
+ version: 1,
85
+ routes: [
86
+ {
67
87
  id: "demo",
68
88
  kind: "page",
69
89
  pattern: "/",
@@ -75,9 +95,9 @@ const manifest: RoutesManifest = {
75
95
  priority: "visible",
76
96
  preload: false,
77
97
  },
78
- },
79
- ],
80
- };
98
+ },
99
+ ],
100
+ };
81
101
 
82
102
  try {
83
103
  const result = await buildClientBundles(manifest, rootDir, {
@@ -0,0 +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
+ });
@@ -24,17 +24,19 @@ async function importBuiltModule(relativePath: string): Promise<Record<string, u
24
24
  * See `__tests__/build-runner.ts` for the subprocess entrypoint and more
25
25
  * background.
26
26
  */
27
- async function runBuildInSubprocess(root: string): Promise<{
28
- success: boolean;
29
- errors: string[];
30
- }> {
27
+ async function runBuildInSubprocess(root: string, mode?: string): Promise<{
28
+ success: boolean;
29
+ errors: string[];
30
+ }> {
31
31
  const runner = path.join(
32
32
  import.meta.dir,
33
33
  "__tests__",
34
34
  "build-runner.ts",
35
35
  );
36
36
  try {
37
- const proc = Bun.spawn([process.execPath, "run", runner, root], {
37
+ const args = [process.execPath, "run", runner, root];
38
+ if (mode) args.push(mode);
39
+ const proc = Bun.spawn(args, {
38
40
  cwd: path.resolve(import.meta.dir, "..", ".."),
39
41
  stdin: "ignore",
40
42
  stdout: "pipe",
@@ -183,4 +185,37 @@ describe("buildClientBundles vendor shims", () => {
183
185
  expect(runtimeSource).toContain("document.getElementById(\"__MANDU_DATA__\")");
184
186
  expect(runtimeSource).toContain("JSON.parse");
185
187
  });
188
+
189
+ test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
190
+ const staleRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-stale-client-module-"));
191
+ try {
192
+ await mkdir(path.join(staleRoot, "app"), { recursive: true });
193
+ await mkdir(path.join(staleRoot, "src", "shared", "contracts"), { recursive: true });
194
+ await writeFile(
195
+ path.join(staleRoot, "package.json"),
196
+ JSON.stringify({ name: "mandu-stale-client-module-test", type: "module" }, null, 2),
197
+ "utf-8",
198
+ );
199
+ await writeFile(
200
+ path.join(staleRoot, "src", "shared", "contracts", "api.ts"),
201
+ 'export const INTERNAL_BASE = process.env.MANDU_INTERNAL_URL ?? "http://localhost:3333";\n',
202
+ "utf-8",
203
+ );
204
+ await writeFile(
205
+ path.join(staleRoot, "app", "page.tsx"),
206
+ 'import { INTERNAL_BASE } from "../src/shared/contracts/api";\n' +
207
+ "export default async function HomePage() {\n" +
208
+ " return <main>{INTERNAL_BASE}</main>;\n" +
209
+ "}\n",
210
+ "utf-8",
211
+ );
212
+
213
+ const staleResult = await runBuildInSubprocess(staleRoot, "server-page-client-module");
214
+ expect(staleResult.success).toBe(false);
215
+ expect(staleResult.errors.join("\n")).toContain("missing \"use client\"");
216
+ expect(await Bun.file(path.join(staleRoot, ".mandu", "client", "index.island.js")).exists()).toBe(false);
217
+ } finally {
218
+ await rm(staleRoot, { recursive: true, force: true });
219
+ }
220
+ });
186
221
  });
@@ -20,9 +20,10 @@ import { safeBuild } from "./safe-build";
20
20
  import { fastRefreshPlugin } from "./fast-refresh-plugin";
21
21
  import { defaultBundlerPlugins } from "./plugins";
22
22
  import type { BunPlugin } from "bun";
23
- import { mark, measure } from "../perf";
24
- import { HMR_PERF } from "../perf/hmr-markers";
25
- import { runOnBundleComplete } from "../plugins/runner";
23
+ import { mark, measure } from "../perf";
24
+ import { HMR_PERF } from "../perf/hmr-markers";
25
+ import { runOnBundleComplete } from "../plugins/runner";
26
+ import { validateClientModuleForBrowserBundle } from "../router/client-entry";
26
27
  import {
27
28
  readVendorCache,
28
29
  writeVendorCache,
@@ -2122,7 +2123,21 @@ export async function buildClientBundles(
2122
2123
  const env = resolveBundlerMode(options);
2123
2124
 
2124
2125
  // 1. Hydration이 필요한 라우트 필터링
2125
- const hydratedRoutes = getHydratedRoutes(manifest);
2126
+ const invalidClientRouteIds = new Set<string>();
2127
+ let hydratedRoutes = getHydratedRoutes(manifest);
2128
+ if (hydratedRoutes.length > 0) {
2129
+ const validRoutes: RouteSpec[] = [];
2130
+ for (const route of hydratedRoutes) {
2131
+ const validationError = await validateClientModuleForBrowserBundle(route, rootDir);
2132
+ if (validationError) {
2133
+ invalidClientRouteIds.add(route.id);
2134
+ errors.push(validationError);
2135
+ continue;
2136
+ }
2137
+ validRoutes.push(route);
2138
+ }
2139
+ hydratedRoutes = validRoutes;
2140
+ }
2126
2141
  const runtimeRoutes = manifest.routes.filter((route) => route.kind === "page" && needsHydration(route));
2127
2142
  const partialFiles = runtimeRoutes.length > 0 ? await scanPartialFiles(rootDir) : [];
2128
2143
 
@@ -2134,7 +2149,7 @@ export async function buildClientBundles(
2134
2149
  // (이전 빌드의 stale 매니페스트 참조 방지)
2135
2150
  if (hydratedRoutes.length === 0 && partialFiles.length === 0) {
2136
2151
  // #185: skipFrameworkBundles 모드에서는 기존 manifest를 그대로 유지 (devtools 재빌드도 스킵)
2137
- if (options.skipFrameworkBundles) {
2152
+ if (options.skipFrameworkBundles && errors.length === 0) {
2138
2153
  const manifestPath = path.join(rootDir, ".mandu/manifest.json");
2139
2154
  try {
2140
2155
  const manifestRaw = await fs.readFile(manifestPath, "utf-8");
@@ -2185,11 +2200,11 @@ export async function buildClientBundles(
2185
2200
  path.join(rootDir, ".mandu/manifest.json"),
2186
2201
  JSON.stringify(emptyManifest, null, 2)
2187
2202
  );
2188
- return {
2189
- success: true,
2190
- outputs: [],
2191
- errors: [],
2192
- manifest: emptyManifest,
2203
+ return {
2204
+ success: errors.length === 0,
2205
+ outputs: [],
2206
+ errors,
2207
+ manifest: emptyManifest,
2193
2208
  stats: {
2194
2209
  totalSize: 0,
2195
2210
  totalGzipSize: 0,
@@ -2228,11 +2243,14 @@ export async function buildClientBundles(
2228
2243
  return buildClientBundles(manifest, rootDir, { ...options, targetRouteIds: undefined });
2229
2244
  }
2230
2245
 
2231
- // Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
2232
- if (outputs.length > 0) {
2233
- for (const output of outputs) {
2234
- if (existingManifest.bundles[output.routeId]) {
2235
- existingManifest.bundles[output.routeId].js = output.outputPath;
2246
+ // Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
2247
+ for (const routeId of invalidClientRouteIds) {
2248
+ delete existingManifest.bundles[routeId];
2249
+ }
2250
+ if (outputs.length > 0 || invalidClientRouteIds.size > 0) {
2251
+ for (const output of outputs) {
2252
+ if (existingManifest.bundles[output.routeId]) {
2253
+ existingManifest.bundles[output.routeId].js = output.outputPath;
2236
2254
  } else {
2237
2255
  const route = targetRoutes.find((r) => r.id === output.routeId);
2238
2256
  const hydration = route ? getRouteHydration(route) : null;
@@ -2244,11 +2262,11 @@ export async function buildClientBundles(
2244
2262
  }
2245
2263
  }
2246
2264
 
2247
- await fs.writeFile(
2248
- path.join(rootDir, ".mandu/manifest.json"),
2249
- JSON.stringify(existingManifest, null, 2)
2250
- );
2251
- }
2265
+ await fs.writeFile(
2266
+ path.join(rootDir, ".mandu/manifest.json"),
2267
+ JSON.stringify(existingManifest, null, 2)
2268
+ );
2269
+ }
2252
2270
  // When all builds failed, do NOT overwrite manifest — keep previous good state
2253
2271
 
2254
2272
  const stats = calculateStats(outputs, startTime);
@@ -2284,9 +2302,13 @@ export async function buildClientBundles(
2284
2302
  "[Mandu] Existing manifest missing required fields (shared/bundles), falling back to full build",
2285
2303
  );
2286
2304
  return buildClientBundles(manifest, rootDir, { ...options, skipFrameworkBundles: false });
2287
- }
2288
-
2289
- // Pre-build validation + 병렬 island 빌드 (framework 번들은 스킵)
2305
+ }
2306
+
2307
+ for (const routeId of invalidClientRouteIds) {
2308
+ delete existingManifest.bundles[routeId];
2309
+ }
2310
+
2311
+ // Pre-build validation + 병렬 island 빌드 (framework 번들은 스킵)
2290
2312
  for (const route of hydratedRoutes) {
2291
2313
  if (!route.clientModule) continue;
2292
2314
  const clientModulePath = path.join(rootDir, route.clientModule);
@@ -18,14 +18,38 @@ import fs from "fs/promises";
18
18
  import { watch as fsWatch, type FSWatcher } from "fs";
19
19
  import { withPerf } from "../perf";
20
20
 
21
- /**
22
- * Tailwind CLI 실행 명령어를 결정한다.
23
- * Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152)
24
- * process.execPath (절대 경로)를 사용해 안정적으로 실행한다.
25
- */
26
- function getTailwindCommand(args: string[]): string[] {
27
- return [process.execPath, "x", ...args];
28
- }
21
+ /**
22
+ * Tailwind CLI 실행 명령어를 결정한다.
23
+ * Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152)
24
+ * `bun run mandu` 환경에서는 process.execPath (bun 절대 경로)를 사용한다.
25
+ * Standalone Mandu binary에서는 process.execPath가 mandu.exe를 가리키므로
26
+ * `mandu x @tailwindcss/cli`로 오해석된다. 이 경우 PATH에서 Bun을 찾는다.
27
+ */
28
+ function isBunExecutable(executablePath: string | undefined): boolean {
29
+ if (!executablePath) return false;
30
+ const base = path.basename(executablePath).toLowerCase();
31
+ return base === "bun" || base === "bun.exe";
32
+ }
33
+
34
+ type WhichExecutable = (command: string) => string | null | undefined;
35
+
36
+ function resolveBunExecutable(
37
+ execPath = process.execPath,
38
+ which: WhichExecutable = (command) => Bun.which(command),
39
+ ): string {
40
+ if (isBunExecutable(execPath)) return execPath;
41
+ const fromPath = which("bun");
42
+ if (fromPath) return fromPath;
43
+ return process.platform === "win32" ? "bun.exe" : "bun";
44
+ }
45
+
46
+ function getTailwindCommand(
47
+ args: string[],
48
+ execPath = process.execPath,
49
+ which?: WhichExecutable,
50
+ ): string[] {
51
+ return [resolveBunExecutable(execPath, which), "x", ...args];
52
+ }
29
53
 
30
54
  // ========== Types ==========
31
55
 
@@ -320,7 +344,13 @@ export function getCSSServerPath(): string {
320
344
  /**
321
345
  * CSS 링크 태그 생성
322
346
  */
323
- export function generateCSSLinkTag(isDev: boolean = false): string {
324
- const cacheBust = isDev ? `?t=${Date.now()}` : "";
325
- return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
326
- }
347
+ export function generateCSSLinkTag(isDev: boolean = false): string {
348
+ const cacheBust = isDev ? `?t=${Date.now()}` : "";
349
+ return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
350
+ }
351
+
352
+ export const __private = {
353
+ getTailwindCommand,
354
+ isBunExecutable,
355
+ resolveBunExecutable,
356
+ };
package/src/index.ts CHANGED
@@ -21,8 +21,9 @@ export const __MANDU_CORE_VERSION__: string = (() => {
21
21
  }
22
22
  })();
23
23
 
24
- export * from "./spec";
25
- export * from "./runtime";
24
+ export * from "./spec";
25
+ export * from "./agent";
26
+ export * from "./runtime";
26
27
  export * from "./generator";
27
28
  export * from "./guard";
28
29
  export * from "./report";
@@ -0,0 +1,71 @@
1
+ import { readFile } from "fs/promises";
2
+ import path from "path";
3
+ import type { RouteSpec } from "../spec/schema";
4
+
5
+ export function normalizeRouteModulePath(value: string | undefined): string {
6
+ return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
7
+ }
8
+
9
+ export function hasUseClientDirective(source: string): boolean {
10
+ return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use client["']\s*;?/.test(source);
11
+ }
12
+
13
+ export function hasUseServerDirective(source: string): boolean {
14
+ return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use server["']\s*;?/.test(source);
15
+ }
16
+
17
+ export function clientModuleIsRouteComponent(route: RouteSpec, clientModule = route.clientModule): boolean {
18
+ if (route.kind !== "page" || !clientModule) return false;
19
+
20
+ const client = normalizeRouteModulePath(clientModule);
21
+ return (
22
+ client === normalizeRouteModulePath(route.componentModule) ||
23
+ client === normalizeRouteModulePath(route.module)
24
+ );
25
+ }
26
+
27
+ async function readRouteModule(rootDir: string, modulePath: string): Promise<string | null> {
28
+ try {
29
+ return await readFile(path.resolve(rootDir, modulePath), "utf-8");
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function shouldPreserveExistingClientModule(
36
+ route: RouteSpec,
37
+ clientModule: string,
38
+ rootDir: string,
39
+ ): Promise<boolean> {
40
+ const source = await readRouteModule(rootDir, clientModule);
41
+ if (source === null) return false;
42
+ if (hasUseServerDirective(source)) return false;
43
+ if (clientModuleIsRouteComponent(route, clientModule)) {
44
+ return hasUseClientDirective(source);
45
+ }
46
+ return true;
47
+ }
48
+
49
+ export async function validateClientModuleForBrowserBundle(
50
+ route: RouteSpec,
51
+ rootDir: string,
52
+ ): Promise<string | null> {
53
+ if (!route.clientModule) return null;
54
+
55
+ const source = await readRouteModule(rootDir, route.clientModule);
56
+ if (source === null) return null;
57
+
58
+ if (hasUseServerDirective(source)) {
59
+ return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
60
+ }
61
+
62
+ if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
63
+ return (
64
+ `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
65
+ `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
66
+ `Remove the stale clientModule from .mandu/routes.manifest.json or use a *.partial.tsx / spec/slots/${route.id}.client.tsx client entry.`
67
+ );
68
+ }
69
+
70
+ return null;
71
+ }
@@ -14,10 +14,11 @@ import { DEFAULT_SCANNER_CONFIG } from "./fs-types";
14
14
  import { scanRoutes } from "./fs-scanner";
15
15
  import { loadManduConfig } from "../config";
16
16
  import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
17
- import {
18
- runOnRouteRegistered,
19
- runOnManifestBuilt,
20
- } from "../plugins/runner";
17
+ import {
18
+ runOnRouteRegistered,
19
+ runOnManifestBuilt,
20
+ } from "../plugins/runner";
21
+ import { shouldPreserveExistingClientModule } from "./client-entry";
21
22
 
22
23
  // ═══════════════════════════════════════════════════════════════════════════
23
24
  // Types
@@ -308,10 +309,17 @@ export async function generateManifest(
308
309
  for (const route of manifest.routes) {
309
310
  const prev = existingMap.get(route.id);
310
311
  if (!prev) continue;
311
- // 사용자가 설정한 clientModule/hydration 보존
312
- if (prev.clientModule && !route.clientModule) {
313
- route.clientModule = prev.clientModule;
314
- }
312
+ // 사용자가 설정한 clientModule/hydration 보존.
313
+ // If app/page.tsx used to be a client page and later becomes a server
314
+ // page, preserving the old clientModule would leak the server graph into
315
+ // the browser bundle.
316
+ if (
317
+ prev.clientModule &&
318
+ !route.clientModule &&
319
+ await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
320
+ ) {
321
+ route.clientModule = prev.clientModule;
322
+ }
315
323
  if (prev.hydration && !route.hydration) {
316
324
  route.hydration = prev.hydration;
317
325
  }
@@ -28,8 +28,9 @@ import {
28
28
  sortRoutesByPriority,
29
29
  getPatternShape,
30
30
  } from "./fs-patterns";
31
- import { mark, measure } from "../perf";
32
- import { METADATA_ROUTES } from "../routes/types";
31
+ import { mark, measure } from "../perf";
32
+ import { METADATA_ROUTES } from "../routes/types";
33
+ import { hasUseClientDirective } from "./client-entry";
33
34
 
34
35
  // ═══════════════════════════════════════════════════════════════════════════
35
36
  // Scanner Class
@@ -390,7 +391,7 @@ export class FSScanner {
390
391
  }
391
392
  } else if (file.type === "page" && pageFileContent) {
392
393
  // page 파일 자체에서 "use client" 확인
393
- const hasUseClient = /^\s*["']use client["']/m.test(pageFileContent);
394
+ const hasUseClient = hasUseClientDirective(pageFileContent);
394
395
  if (hasUseClient) {
395
396
  clientModule = modulePath;
396
397
  }