@mandujs/core 0.54.5 → 0.54.7
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 +1 -1
- package/src/agent/__tests__/context.test.ts +237 -237
- package/src/agent/types.ts +308 -308
- package/src/agent/verify.ts +406 -406
- package/src/bundler/__tests__/build-runner.ts +36 -18
- package/src/bundler/__tests__/cold-start.test.ts +25 -1
- package/src/bundler/build.test.ts +35 -0
- package/src/bundler/build.ts +41 -16
- package/src/generator/generate.ts +30 -25
- package/src/resource/__tests__/generator.test.ts +6 -4
- package/src/resource/ddl/__tests__/emit.test.ts +165 -49
- package/src/resource/ddl/emit.ts +146 -51
- package/src/resource/generator-schema.ts +11 -15
- package/src/router/client-entry.test.ts +71 -0
- package/src/router/client-entry.ts +286 -0
- package/src/router/fs-scanner.ts +21 -8
- package/src/runtime/server.ts +16 -8
- package/src/watcher/__tests__/watcher.test.ts +59 -0
- package/src/watcher/watcher.ts +61 -22
package/src/router/fs-scanner.ts
CHANGED
|
@@ -30,7 +30,11 @@ import {
|
|
|
30
30
|
} from "./fs-patterns";
|
|
31
31
|
import { mark, measure } from "../perf";
|
|
32
32
|
import { METADATA_ROUTES } from "../routes/types";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
findRouteLevelClientComponentImport,
|
|
35
|
+
hasUseClientDirective,
|
|
36
|
+
resolveClientImportModulePath,
|
|
37
|
+
} from "./client-entry";
|
|
34
38
|
|
|
35
39
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
40
|
// Scanner Class
|
|
@@ -229,7 +233,7 @@ export class FSScanner {
|
|
|
229
233
|
*/
|
|
230
234
|
private async createRouteConfigs(
|
|
231
235
|
files: ScannedFile[],
|
|
232
|
-
|
|
236
|
+
rootDir: string
|
|
233
237
|
): Promise<{ routes: FSRouteConfig[]; routeErrors: ScanError[] }> {
|
|
234
238
|
const routes: FSRouteConfig[] = [];
|
|
235
239
|
const routeErrors: ScanError[] = [];
|
|
@@ -389,13 +393,22 @@ export class FSScanner {
|
|
|
389
393
|
conflictsWith: islands[0].absolutePath,
|
|
390
394
|
});
|
|
391
395
|
}
|
|
392
|
-
} else if (file.type === "page" && pageFileContent) {
|
|
393
|
-
// page 파일 자체에서 "use client" 확인
|
|
396
|
+
} else if (file.type === "page" && pageFileContent) {
|
|
397
|
+
// page 파일 자체에서 "use client" 확인
|
|
394
398
|
const hasUseClient = hasUseClientDirective(pageFileContent);
|
|
395
|
-
if (hasUseClient) {
|
|
396
|
-
clientModule = modulePath;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
+
if (hasUseClient) {
|
|
400
|
+
clientModule = modulePath;
|
|
401
|
+
} else {
|
|
402
|
+
const routeLevelClientImport = findRouteLevelClientComponentImport(pageFileContent);
|
|
403
|
+
if (routeLevelClientImport) {
|
|
404
|
+
clientModule = await resolveClientImportModulePath(
|
|
405
|
+
rootDir,
|
|
406
|
+
modulePath,
|
|
407
|
+
routeLevelClientImport.module,
|
|
408
|
+
) ?? undefined;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
399
412
|
|
|
400
413
|
// 로딩/에러/404 모듈 찾기 — nearest-ancestor resolution.
|
|
401
414
|
// Phase 18.β: `not-found.tsx` joins `loading.tsx`/`error.tsx` in
|
package/src/runtime/server.ts
CHANGED
|
@@ -2232,18 +2232,26 @@ async function renderPageSSR(
|
|
|
2232
2232
|
|
|
2233
2233
|
// Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
|
|
2234
2234
|
// 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
|
|
2235
|
-
const
|
|
2235
|
+
const needsIslandHydration = !!(
|
|
2236
2236
|
route.hydration &&
|
|
2237
2237
|
route.hydration.strategy !== "none" &&
|
|
2238
2238
|
settings.bundleManifest
|
|
2239
2239
|
);
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
"
|
|
2240
|
+
const routeBundle = settings.bundleManifest?.bundles[route.id];
|
|
2241
|
+
const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
|
|
2242
|
+
const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0;
|
|
2243
|
+
|
|
2244
|
+
if (needsIslandHydration && !needsIslandWrap && settings.isDev) {
|
|
2245
|
+
console.warn(
|
|
2246
|
+
`[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
|
|
2247
|
+
`Run mandu build/generate and ensure the route has a clientModule.`,
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
if (needsIslandWrap) {
|
|
2252
|
+
const priority = route.hydration!.priority || "visible";
|
|
2253
|
+
app = React.createElement("div", {
|
|
2254
|
+
"data-mandu-island": route.id,
|
|
2247
2255
|
"data-mandu-src": bundleSrc,
|
|
2248
2256
|
"data-mandu-priority": priority,
|
|
2249
2257
|
style: { display: "contents" },
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { hasRecentGenerateStamp } from "../watcher";
|
|
6
|
+
|
|
7
|
+
const tmpRoots: string[] = [];
|
|
8
|
+
|
|
9
|
+
function makeRoot(): string {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "mandu-watch-"));
|
|
11
|
+
tmpRoots.push(root);
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
for (const root of tmpRoots.splice(0)) {
|
|
17
|
+
rmSync(root, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("hasRecentGenerateStamp suppresses generated events using the root stamp", () => {
|
|
22
|
+
const root = makeRoot();
|
|
23
|
+
const now = 1_700_000_000_000;
|
|
24
|
+
mkdirSync(join(root, ".mandu"), { recursive: true });
|
|
25
|
+
writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 500));
|
|
26
|
+
|
|
27
|
+
const generatedFile = join(
|
|
28
|
+
root,
|
|
29
|
+
".mandu",
|
|
30
|
+
"generated",
|
|
31
|
+
"server",
|
|
32
|
+
"repos",
|
|
33
|
+
"notification.repo.ts",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(hasRecentGenerateStamp(root, generatedFile, now)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("hasRecentGenerateStamp does not suppress stale generated events", () => {
|
|
40
|
+
const root = makeRoot();
|
|
41
|
+
const now = 1_700_000_000_000;
|
|
42
|
+
mkdirSync(join(root, ".mandu"), { recursive: true });
|
|
43
|
+
writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 30_000));
|
|
44
|
+
|
|
45
|
+
const generatedFile = join(root, ".mandu", "generated", "server", "old.route.ts");
|
|
46
|
+
|
|
47
|
+
expect(hasRecentGenerateStamp(root, generatedFile, now)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("hasRecentGenerateStamp does not suppress source files after generation", () => {
|
|
51
|
+
const root = makeRoot();
|
|
52
|
+
const now = 1_700_000_000_000;
|
|
53
|
+
mkdirSync(join(root, ".mandu"), { recursive: true });
|
|
54
|
+
writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 500));
|
|
55
|
+
|
|
56
|
+
const sourceFile = join(root, "app", "page.tsx");
|
|
57
|
+
|
|
58
|
+
expect(hasRecentGenerateStamp(root, sourceFile, now)).toBe(false);
|
|
59
|
+
});
|
package/src/watcher/watcher.ts
CHANGED
|
@@ -61,13 +61,63 @@ const DEFAULT_CONFIG: Partial<WatcherConfig> = {
|
|
|
61
61
|
* These cause EISDIR/ENOENT errors when file watchers try to access them.
|
|
62
62
|
* See: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
63
63
|
*/
|
|
64
|
-
const WINDOWS_RESERVED_NAMES = new Set([
|
|
65
|
-
"CON", "PRN", "AUX", "NUL",
|
|
66
|
-
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
67
|
-
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const WINDOWS_RESERVED_NAMES = new Set([
|
|
65
|
+
"CON", "PRN", "AUX", "NUL",
|
|
66
|
+
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
67
|
+
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const GENERATE_STAMP_SUPPRESS_MS = 10_000;
|
|
71
|
+
|
|
72
|
+
function readGenerateStamp(stampFile: string): number | null {
|
|
73
|
+
try {
|
|
74
|
+
const stamp = Number.parseInt(fs.readFileSync(stampFile, "utf-8"), 10);
|
|
75
|
+
return Number.isFinite(stamp) ? stamp : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isManduGeneratedPath(rootDir: string, filePath: string): boolean {
|
|
82
|
+
const relativePath = path.relative(rootDir, filePath).replace(/\\/g, "/");
|
|
83
|
+
return relativePath === ".mandu/generated" || relativePath.startsWith(".mandu/generated/");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hasRecentGenerateStamp(
|
|
87
|
+
rootDir: string,
|
|
88
|
+
filePath: string,
|
|
89
|
+
now: number = Date.now()
|
|
90
|
+
): boolean {
|
|
91
|
+
if (!isManduGeneratedPath(rootDir, filePath)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const candidates = new Set<string>([
|
|
96
|
+
path.join(rootDir, ".mandu", "generate.stamp"),
|
|
97
|
+
]);
|
|
98
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
99
|
+
let stampDir = path.dirname(path.resolve(filePath));
|
|
100
|
+
|
|
101
|
+
while (stampDir !== path.dirname(stampDir)) {
|
|
102
|
+
candidates.add(path.join(stampDir, ".mandu", "generate.stamp"));
|
|
103
|
+
if (stampDir === resolvedRoot) break;
|
|
104
|
+
stampDir = path.dirname(stampDir);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const stampFile of candidates) {
|
|
108
|
+
const stamp = readGenerateStamp(stampFile);
|
|
109
|
+
if (stamp === null) continue;
|
|
110
|
+
|
|
111
|
+
const ageMs = now - stamp;
|
|
112
|
+
if (ageMs >= 0 && ageMs < GENERATE_STAMP_SUPPRESS_MS) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class FileWatcher {
|
|
71
121
|
private config: WatcherConfig;
|
|
72
122
|
private chokidarWatcher: FSWatcher | null = null;
|
|
73
123
|
private handlers: Set<WatchEventHandler> = new Set();
|
|
@@ -311,21 +361,10 @@ export class FileWatcher {
|
|
|
311
361
|
|
|
312
362
|
const { rootDir } = this.config;
|
|
313
363
|
|
|
314
|
-
// Cross-process: skip
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const stampFile = path.join(stampDir, ".mandu", "generate.stamp");
|
|
319
|
-
try {
|
|
320
|
-
const stamp = parseInt(fs.readFileSync(stampFile, "utf-8"), 10);
|
|
321
|
-
if (Date.now() - stamp < 2000) return;
|
|
322
|
-
break;
|
|
323
|
-
} catch {}
|
|
324
|
-
stampDir = path.dirname(stampDir);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// Validate file against rules
|
|
364
|
+
// Cross-process: skip generated-file churn from a recent mandu generate.
|
|
365
|
+
if (hasRecentGenerateStamp(rootDir, filePath)) return;
|
|
366
|
+
|
|
367
|
+
// Validate file against rules
|
|
329
368
|
try {
|
|
330
369
|
const warnings = await validateFile(filePath, event, rootDir);
|
|
331
370
|
|