@mandujs/core 0.18.22 → 0.19.2
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/README.ko.md +0 -14
- package/package.json +4 -1
- package/src/brain/architecture/analyzer.ts +4 -4
- package/src/brain/doctor/analyzer.ts +18 -14
- package/src/bundler/build.test.ts +127 -0
- package/src/bundler/build.ts +291 -113
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -0
- package/src/change/snapshot.ts +4 -23
- package/src/change/types.ts +2 -3
- package/src/client/Form.tsx +105 -0
- package/src/client/__tests__/use-sse.test.ts +153 -0
- package/src/client/hooks.ts +105 -6
- package/src/client/index.ts +35 -6
- package/src/client/router.ts +670 -433
- package/src/client/rpc.ts +140 -0
- package/src/client/runtime.ts +24 -21
- package/src/client/use-fetch.ts +239 -0
- package/src/client/use-head.ts +197 -0
- package/src/client/use-sse.ts +378 -0
- package/src/components/Image.tsx +162 -0
- package/src/config/mandu.ts +5 -0
- package/src/config/validate.ts +34 -0
- package/src/content/index.ts +5 -1
- package/src/devtools/client/catchers/error-catcher.ts +17 -0
- package/src/devtools/client/catchers/network-proxy.ts +390 -367
- package/src/devtools/client/components/kitchen-root.tsx +479 -467
- package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/panel-container.tsx +332 -312
- package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/types.ts +345 -319
- package/src/filling/context.ts +65 -0
- package/src/filling/filling.ts +336 -14
- package/src/filling/index.ts +5 -1
- package/src/filling/session.ts +216 -0
- package/src/filling/ws.ts +78 -0
- package/src/generator/generate.ts +2 -2
- package/src/guard/auto-correct.ts +0 -29
- package/src/guard/check.ts +14 -31
- package/src/guard/presets/index.ts +296 -294
- package/src/guard/rules.ts +15 -19
- package/src/guard/validator.ts +834 -834
- package/src/index.ts +5 -1
- package/src/island/index.ts +373 -304
- package/src/kitchen/api/contract-api.ts +225 -0
- package/src/kitchen/api/diff-parser.ts +108 -0
- package/src/kitchen/api/file-api.ts +273 -0
- package/src/kitchen/api/guard-api.ts +83 -0
- package/src/kitchen/api/guard-decisions.ts +100 -0
- package/src/kitchen/api/routes-api.ts +50 -0
- package/src/kitchen/index.ts +21 -0
- package/src/kitchen/kitchen-handler.ts +256 -0
- package/src/kitchen/kitchen-ui.ts +1732 -0
- package/src/kitchen/stream/activity-sse.ts +145 -0
- package/src/kitchen/stream/file-tailer.ts +99 -0
- package/src/middleware/compress.ts +62 -0
- package/src/middleware/cors.ts +47 -0
- package/src/middleware/index.ts +10 -0
- package/src/middleware/jwt.ts +134 -0
- package/src/middleware/logger.ts +58 -0
- package/src/middleware/timeout.ts +55 -0
- package/src/paths.ts +0 -4
- package/src/plugins/hooks.ts +64 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/types.ts +5 -0
- package/src/report/build.ts +0 -6
- package/src/resource/__tests__/backward-compat.test.ts +0 -1
- package/src/router/fs-patterns.ts +11 -1
- package/src/router/fs-routes.ts +78 -14
- package/src/router/fs-scanner.ts +2 -2
- package/src/router/fs-types.ts +2 -1
- package/src/runtime/adapter-bun.ts +62 -0
- package/src/runtime/adapter.ts +47 -0
- package/src/runtime/cache.ts +310 -0
- package/src/runtime/handler.ts +65 -0
- package/src/runtime/image-handler.ts +195 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/server.ts +686 -92
- package/src/runtime/ssr.ts +55 -29
- package/src/runtime/streaming-ssr.ts +106 -82
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +144 -0
- package/src/watcher/watcher.ts +27 -1
- package/src/spec/lock.ts +0 -56
package/src/bundler/dev.ts
CHANGED
|
@@ -206,7 +206,13 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
206
206
|
if (pendingBuildFile) {
|
|
207
207
|
const next = pendingBuildFile;
|
|
208
208
|
pendingBuildFile = null;
|
|
209
|
-
|
|
209
|
+
// Catch errors to prevent unhandled promise rejection from killing the watcher (#10)
|
|
210
|
+
try {
|
|
211
|
+
await handleFileChange(next);
|
|
212
|
+
} catch (retryError) {
|
|
213
|
+
console.error(`❌ Retry build error:`, retryError instanceof Error ? retryError.message : String(retryError));
|
|
214
|
+
console.log(` ⏳ Waiting for next file change to retry...`);
|
|
215
|
+
}
|
|
210
216
|
}
|
|
211
217
|
}
|
|
212
218
|
};
|
|
@@ -247,6 +253,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
247
253
|
} catch (error) {
|
|
248
254
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
249
255
|
console.error(`❌ Build error:`, err.message);
|
|
256
|
+
console.log(` ⏳ Waiting for next file change to retry...`);
|
|
250
257
|
onError?.(err, "*");
|
|
251
258
|
}
|
|
252
259
|
return;
|
|
@@ -303,6 +310,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
303
310
|
});
|
|
304
311
|
} else {
|
|
305
312
|
console.error(`❌ Build failed:`, result.errors);
|
|
313
|
+
console.log(` ⏳ Previous bundle preserved. Waiting for next file change to retry...`);
|
|
306
314
|
onRebuild?.({
|
|
307
315
|
routeId,
|
|
308
316
|
success: false,
|
|
@@ -313,6 +321,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
313
321
|
} catch (error) {
|
|
314
322
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
315
323
|
console.error(`❌ Build error:`, err.message);
|
|
324
|
+
console.log(` ⏳ Previous bundle preserved. Waiting for next file change to retry...`);
|
|
316
325
|
onError?.(err, routeId);
|
|
317
326
|
}
|
|
318
327
|
};
|
|
@@ -380,7 +389,17 @@ export interface HMRServer {
|
|
|
380
389
|
}
|
|
381
390
|
|
|
382
391
|
export interface HMRMessage {
|
|
383
|
-
type:
|
|
392
|
+
type:
|
|
393
|
+
| "connected"
|
|
394
|
+
| "reload"
|
|
395
|
+
| "island-update"
|
|
396
|
+
| "layout-update"
|
|
397
|
+
| "css-update"
|
|
398
|
+
| "error"
|
|
399
|
+
| "ping"
|
|
400
|
+
| "guard-violation"
|
|
401
|
+
| "kitchen:file-change"
|
|
402
|
+
| "kitchen:guard-decision";
|
|
384
403
|
data?: {
|
|
385
404
|
routeId?: string;
|
|
386
405
|
layoutPath?: string;
|
|
@@ -389,6 +408,9 @@ export interface HMRMessage {
|
|
|
389
408
|
timestamp?: number;
|
|
390
409
|
file?: string;
|
|
391
410
|
violations?: Array<{ line: number; message: string }>;
|
|
411
|
+
changeType?: "add" | "change" | "delete";
|
|
412
|
+
action?: "approve" | "reject";
|
|
413
|
+
ruleId?: string;
|
|
392
414
|
};
|
|
393
415
|
}
|
|
394
416
|
|
|
@@ -627,6 +649,37 @@ export function generateHMRClientScript(port: number): string {
|
|
|
627
649
|
showErrorOverlay(message.data?.message);
|
|
628
650
|
break;
|
|
629
651
|
|
|
652
|
+
case 'guard-violation':
|
|
653
|
+
console.warn('[Mandu HMR] Guard violation:', message.data?.file);
|
|
654
|
+
if (window.__MANDU_DEVTOOLS_HOOK__) {
|
|
655
|
+
window.__MANDU_DEVTOOLS_HOOK__.emit({
|
|
656
|
+
type: 'guard:violation',
|
|
657
|
+
timestamp: Date.now(),
|
|
658
|
+
data: message.data
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
break;
|
|
662
|
+
|
|
663
|
+
case 'kitchen:file-change':
|
|
664
|
+
if (window.__MANDU_DEVTOOLS_HOOK__) {
|
|
665
|
+
window.__MANDU_DEVTOOLS_HOOK__.emit({
|
|
666
|
+
type: 'kitchen:file-change',
|
|
667
|
+
timestamp: Date.now(),
|
|
668
|
+
data: message.data
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
break;
|
|
672
|
+
|
|
673
|
+
case 'kitchen:guard-decision':
|
|
674
|
+
if (window.__MANDU_DEVTOOLS_HOOK__) {
|
|
675
|
+
window.__MANDU_DEVTOOLS_HOOK__.emit({
|
|
676
|
+
type: 'kitchen:guard-decision',
|
|
677
|
+
timestamp: Date.now(),
|
|
678
|
+
data: message.data
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
break;
|
|
682
|
+
|
|
630
683
|
case 'pong':
|
|
631
684
|
// 연결 확인
|
|
632
685
|
break;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Prerender Engine
|
|
3
|
+
* 빌드 타임에 정적 HTML 생성 (SSG)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from "path";
|
|
7
|
+
import fs from "fs/promises";
|
|
8
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
9
|
+
|
|
10
|
+
// ========== Types ==========
|
|
11
|
+
|
|
12
|
+
export interface PrerenderOptions {
|
|
13
|
+
/** 프로젝트 루트 */
|
|
14
|
+
rootDir: string;
|
|
15
|
+
/** 출력 디렉토리 (기본: ".mandu/static") */
|
|
16
|
+
outDir?: string;
|
|
17
|
+
/** 프리렌더할 추가 경로 목록 */
|
|
18
|
+
routes?: string[];
|
|
19
|
+
/** 링크 크롤링으로 자동 발견 (기본: false) */
|
|
20
|
+
crawl?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PrerenderResult {
|
|
24
|
+
/** 생성된 페이지 수 */
|
|
25
|
+
generated: number;
|
|
26
|
+
/** 생성된 경로별 정보 */
|
|
27
|
+
pages: PrerenderPageResult[];
|
|
28
|
+
/** 에러 목록 */
|
|
29
|
+
errors: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PrerenderPageResult {
|
|
33
|
+
path: string;
|
|
34
|
+
size: number;
|
|
35
|
+
duration: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ========== Implementation ==========
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 정적 라우트를 HTML로 프리렌더링
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const result = await prerenderRoutes(manifest, fetchHandler, {
|
|
46
|
+
* rootDir: process.cwd(),
|
|
47
|
+
* routes: ["/about", "/blog/hello-world"],
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export async function prerenderRoutes(
|
|
52
|
+
manifest: RoutesManifest,
|
|
53
|
+
fetchHandler: (req: Request) => Promise<Response>,
|
|
54
|
+
options: PrerenderOptions
|
|
55
|
+
): Promise<PrerenderResult> {
|
|
56
|
+
const { rootDir, outDir = ".mandu/static", crawl = false } = options;
|
|
57
|
+
const outputDir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
|
|
58
|
+
|
|
59
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
60
|
+
|
|
61
|
+
const pages: PrerenderPageResult[] = [];
|
|
62
|
+
const errors: string[] = [];
|
|
63
|
+
const renderedPaths = new Set<string>();
|
|
64
|
+
|
|
65
|
+
// 1. 명시적으로 지정된 경로 수집
|
|
66
|
+
const pathsToRender = new Set<string>(options.routes ?? []);
|
|
67
|
+
|
|
68
|
+
// 2. 매니페스트에서 정적 페이지 라우트 수집 (동적 파라미터 없는 것)
|
|
69
|
+
for (const route of manifest.routes) {
|
|
70
|
+
if (route.kind === "page" && !route.pattern.includes(":")) {
|
|
71
|
+
pathsToRender.add(route.pattern);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. 동적 라우트의 generateStaticParams 수집
|
|
76
|
+
for (const route of manifest.routes) {
|
|
77
|
+
if (route.kind === "page" && route.pattern.includes(":")) {
|
|
78
|
+
try {
|
|
79
|
+
const modulePath = path.join(rootDir, route.module).replace(/\\/g, "/");
|
|
80
|
+
const mod = await import(modulePath);
|
|
81
|
+
if (typeof mod.generateStaticParams === "function") {
|
|
82
|
+
const paramSets = await mod.generateStaticParams();
|
|
83
|
+
if (Array.isArray(paramSets)) {
|
|
84
|
+
for (const params of paramSets) {
|
|
85
|
+
const resolvedPath = resolvePattern(route.pattern, params);
|
|
86
|
+
pathsToRender.add(resolvedPath);
|
|
87
|
+
}
|
|
88
|
+
} else if (paramSets) {
|
|
89
|
+
console.warn(`[Mandu Prerender] generateStaticParams() for ${route.pattern} returned non-array. Expected an array of param objects.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// generateStaticParams 없으면 스킵
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 4. 각 경로를 렌더링
|
|
99
|
+
for (const pathname of pathsToRender) {
|
|
100
|
+
if (renderedPaths.has(pathname)) continue;
|
|
101
|
+
renderedPaths.add(pathname);
|
|
102
|
+
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
try {
|
|
105
|
+
const request = new Request(`http://localhost${pathname}`);
|
|
106
|
+
const response = await fetchHandler(request);
|
|
107
|
+
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
errors.push(`[${pathname}] HTTP ${response.status}`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const html = await response.text();
|
|
114
|
+
const filePath = getOutputPath(outputDir, pathname);
|
|
115
|
+
|
|
116
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
117
|
+
await fs.writeFile(filePath, html, "utf-8");
|
|
118
|
+
|
|
119
|
+
const duration = Date.now() - start;
|
|
120
|
+
pages.push({ path: pathname, size: html.length, duration });
|
|
121
|
+
|
|
122
|
+
// 5. 크롤링: 생성된 HTML에서 내부 링크 추출
|
|
123
|
+
if (crawl) {
|
|
124
|
+
const links = extractInternalLinks(html, pathname);
|
|
125
|
+
for (const link of links) {
|
|
126
|
+
if (!renderedPaths.has(link) && !pathsToRender.has(link)) {
|
|
127
|
+
pathsToRender.add(link);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
errors.push(`[${pathname}] ${message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { generated: pages.length, pages, errors };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ========== Helpers ==========
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 라우트 패턴에 파라미터를 대입하여 실제 경로 생성
|
|
144
|
+
*/
|
|
145
|
+
function resolvePattern(pattern: string, params: Record<string, string>): string {
|
|
146
|
+
let result = pattern;
|
|
147
|
+
for (const [key, value] of Object.entries(params)) {
|
|
148
|
+
// catch-all (:param*) / optional catch-all (:param*?) 지원
|
|
149
|
+
const paramRegex = new RegExp(`:${key}\\*\\??`);
|
|
150
|
+
if (paramRegex.test(result)) {
|
|
151
|
+
// catch-all: 각 세그먼트를 개별 인코딩 (슬래시 보존)
|
|
152
|
+
const encoded = value.split("/").map(encodeURIComponent).join("/");
|
|
153
|
+
result = result.replace(paramRegex, encoded);
|
|
154
|
+
} else {
|
|
155
|
+
result = result.replace(`:${key}`, encodeURIComponent(value));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 출력 파일 경로 생성
|
|
163
|
+
* /about → .mandu/static/about/index.html
|
|
164
|
+
* / → .mandu/static/index.html
|
|
165
|
+
*/
|
|
166
|
+
function getOutputPath(outDir: string, pathname: string): string {
|
|
167
|
+
const trimmed = pathname === "/" ? "/" : pathname.replace(/\/+$/, "");
|
|
168
|
+
if (trimmed === "/") return path.join(outDir, "index.html");
|
|
169
|
+
// /blog/post → .mandu/static/blog/post/index.html (clean URL)
|
|
170
|
+
return path.join(outDir, trimmed, "index.html");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* HTML에서 내부 링크 추출 (크롤링용)
|
|
175
|
+
*/
|
|
176
|
+
function extractInternalLinks(html: string, currentPath: string): string[] {
|
|
177
|
+
const links: string[] = [];
|
|
178
|
+
const hrefRegex = /href=["']([^"']+)["']/g;
|
|
179
|
+
let match: RegExpExecArray | null;
|
|
180
|
+
|
|
181
|
+
while ((match = hrefRegex.exec(html)) !== null) {
|
|
182
|
+
const href = match[1];
|
|
183
|
+
// 내부 링크만 (절대 경로이면서 프로토콜 없는 것)
|
|
184
|
+
if (href.startsWith("/") && !href.startsWith("//")) {
|
|
185
|
+
// 쿼리스트링/해시 제거
|
|
186
|
+
const cleanPath = href.split("?")[0].split("#")[0];
|
|
187
|
+
// 정적 파일 제외
|
|
188
|
+
if (!cleanPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/)) {
|
|
189
|
+
links.push(cleanPath);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return [...new Set(links)];
|
|
195
|
+
}
|
package/src/change/snapshot.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import type { Snapshot, RestoreResult, ConfigSnapshot } from "./types";
|
|
3
3
|
import type { RoutesManifest } from "../spec/schema";
|
|
4
|
-
import type { SpecLock } from "../spec/lock";
|
|
5
4
|
import {
|
|
6
5
|
readLockfile,
|
|
7
6
|
writeLockfile,
|
|
@@ -14,7 +13,6 @@ import { validateAndReport } from "../config";
|
|
|
14
13
|
const MANDU_DIR = ".mandu";
|
|
15
14
|
const SPEC_DIR = "spec";
|
|
16
15
|
const MANIFEST_FILE = "routes.manifest.json";
|
|
17
|
-
const LOCK_FILE = "spec.lock.json";
|
|
18
16
|
const SLOTS_DIR = "slots";
|
|
19
17
|
const HISTORY_DIR = "history";
|
|
20
18
|
const SNAPSHOTS_DIR = "snapshots";
|
|
@@ -74,7 +72,6 @@ async function collectSlotContents(rootDir: string): Promise<Record<string, stri
|
|
|
74
72
|
export async function createSnapshot(rootDir: string): Promise<Snapshot> {
|
|
75
73
|
const manduDir = path.join(rootDir, MANDU_DIR);
|
|
76
74
|
const manifestPath = path.join(manduDir, MANIFEST_FILE);
|
|
77
|
-
const lockPath = path.join(manduDir, LOCK_FILE);
|
|
78
75
|
|
|
79
76
|
// Manifest 읽기 (필수)
|
|
80
77
|
const manifestFile = Bun.file(manifestPath);
|
|
@@ -83,12 +80,8 @@ export async function createSnapshot(rootDir: string): Promise<Snapshot> {
|
|
|
83
80
|
}
|
|
84
81
|
const manifest: RoutesManifest = await manifestFile.json();
|
|
85
82
|
|
|
86
|
-
// Lock
|
|
87
|
-
|
|
88
|
-
const lockFile = Bun.file(lockPath);
|
|
89
|
-
if (await lockFile.exists()) {
|
|
90
|
-
lock = await lockFile.json();
|
|
91
|
-
}
|
|
83
|
+
// Lock은 FS-First 전환으로 제거됨 - 하위호환을 위해 null 유지
|
|
84
|
+
const lock = null;
|
|
92
85
|
|
|
93
86
|
// Slot 내용 수집
|
|
94
87
|
const slotContents = await collectSlotContents(rootDir);
|
|
@@ -171,7 +164,6 @@ export async function readSnapshotById(rootDir: string, snapshotId: string): Pro
|
|
|
171
164
|
export async function restoreSnapshot(rootDir: string, snapshot: Snapshot): Promise<RestoreResult> {
|
|
172
165
|
const manduDir = path.join(rootDir, MANDU_DIR);
|
|
173
166
|
const manifestPath = path.join(manduDir, MANIFEST_FILE);
|
|
174
|
-
const lockPath = path.join(manduDir, LOCK_FILE);
|
|
175
167
|
const slotsDir = path.join(rootDir, SPEC_DIR, SLOTS_DIR);
|
|
176
168
|
|
|
177
169
|
const restoredFiles: string[] = [];
|
|
@@ -187,18 +179,7 @@ export async function restoreSnapshot(rootDir: string, snapshot: Snapshot): Prom
|
|
|
187
179
|
errors.push(`Failed to restore manifest: ${error instanceof Error ? error.message : String(error)}`);
|
|
188
180
|
}
|
|
189
181
|
|
|
190
|
-
// 2.
|
|
191
|
-
if (snapshot.lock) {
|
|
192
|
-
try {
|
|
193
|
-
await Bun.write(lockPath, JSON.stringify(snapshot.lock, null, 2));
|
|
194
|
-
restoredFiles.push(LOCK_FILE);
|
|
195
|
-
} catch (error) {
|
|
196
|
-
failedFiles.push(LOCK_FILE);
|
|
197
|
-
errors.push(`Failed to restore lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// 3. Slot 파일들 복원
|
|
182
|
+
// 2. Slot 파일들 복원
|
|
202
183
|
for (const [relativePath, content] of Object.entries(snapshot.slotContents)) {
|
|
203
184
|
const filePath = path.join(slotsDir, relativePath);
|
|
204
185
|
try {
|
|
@@ -216,7 +197,7 @@ export async function restoreSnapshot(rootDir: string, snapshot: Snapshot): Prom
|
|
|
216
197
|
}
|
|
217
198
|
}
|
|
218
199
|
|
|
219
|
-
//
|
|
200
|
+
// 3. Config Lockfile 복원 (있는 경우)
|
|
220
201
|
if (snapshot.configSnapshot) {
|
|
221
202
|
try {
|
|
222
203
|
await writeLockfile(rootDir, snapshot.configSnapshot.lockfile);
|
package/src/change/types.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { RoutesManifest } from "../spec/schema";
|
|
2
|
-
import type { SpecLock } from "../spec/lock";
|
|
3
2
|
import type { ManduLockfile } from "../lockfile";
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -30,8 +29,8 @@ export interface Snapshot {
|
|
|
30
29
|
timestamp: string;
|
|
31
30
|
/** routes.manifest.json 내용 (~1KB) */
|
|
32
31
|
manifest: RoutesManifest;
|
|
33
|
-
/**
|
|
34
|
-
lock:
|
|
32
|
+
/** Legacy lock 데이터 (하위호환용, 새 스냅샷에서는 항상 null) */
|
|
33
|
+
lock: { routesHash: string; updatedAt: string } | null;
|
|
35
34
|
/** Slot 파일 내용만 저장 (Generated 파일은 재생성 가능) */
|
|
36
35
|
slotContents: Record<string, string>;
|
|
37
36
|
/** 설정 스냅샷 (Lockfile 포함) - ont-run 통합 */
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu <Form> Component
|
|
3
|
+
* Progressive Enhancement: JS 없으면 일반 HTML form, JS 있으면 fetch 기반 제출
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback, useRef, type FormEvent, type ReactNode } from "react";
|
|
7
|
+
import { submitAction, type ActionResult } from "./router";
|
|
8
|
+
|
|
9
|
+
export interface FormState {
|
|
10
|
+
submitting: boolean;
|
|
11
|
+
error: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FormProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, "action" | "children"> {
|
|
15
|
+
/** 제출 대상 URL */
|
|
16
|
+
action: string;
|
|
17
|
+
/** Action 이름 (서버의 filling.action(name)과 매칭) */
|
|
18
|
+
actionName?: string;
|
|
19
|
+
/** HTTP 메서드 */
|
|
20
|
+
method?: "post" | "put" | "patch" | "delete";
|
|
21
|
+
/** JS에서 fetch 방식으로 전환 (기본: true, false면 일반 HTML form 제출) */
|
|
22
|
+
enhance?: boolean;
|
|
23
|
+
/** Action 성공 후 콜백 */
|
|
24
|
+
onActionSuccess?: (result: ActionResult) => void;
|
|
25
|
+
/** Action 실패 후 콜백 */
|
|
26
|
+
onActionError?: (error: Error) => void;
|
|
27
|
+
/** render props 또는 일반 children */
|
|
28
|
+
children: ReactNode | ((state: FormState) => ReactNode);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Progressive Enhancement Form 컴포넌트
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* <Form action="/api/todos" actionName="create">
|
|
37
|
+
* <input name="title" required />
|
|
38
|
+
* <button type="submit">추가</button>
|
|
39
|
+
* </Form>
|
|
40
|
+
*
|
|
41
|
+
* // render props로 제출 상태 접근
|
|
42
|
+
* <Form action="/api/todos" actionName="create">
|
|
43
|
+
* {({ submitting, error }) => (
|
|
44
|
+
* <>
|
|
45
|
+
* <input name="title" required />
|
|
46
|
+
* <button type="submit" disabled={submitting}>
|
|
47
|
+
* {submitting ? "처리 중..." : "추가"}
|
|
48
|
+
* </button>
|
|
49
|
+
* {error && <p>{error}</p>}
|
|
50
|
+
* </>
|
|
51
|
+
* )}
|
|
52
|
+
* </Form>
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function Form({
|
|
56
|
+
action,
|
|
57
|
+
actionName = "default",
|
|
58
|
+
method = "post",
|
|
59
|
+
enhance = true,
|
|
60
|
+
onActionSuccess,
|
|
61
|
+
onActionError,
|
|
62
|
+
children,
|
|
63
|
+
...rest
|
|
64
|
+
}: FormProps) {
|
|
65
|
+
const [state, setState] = useState<FormState>({ submitting: false, error: null });
|
|
66
|
+
const submittingRef = useRef(false);
|
|
67
|
+
const formMethod = method === "post" ? "post" : "post";
|
|
68
|
+
|
|
69
|
+
const handleSubmit = useCallback(async (e: FormEvent<HTMLFormElement>) => {
|
|
70
|
+
if (!enhance) return;
|
|
71
|
+
if (submittingRef.current) return; // 이중 제출 방지
|
|
72
|
+
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
submittingRef.current = true;
|
|
75
|
+
setState({ submitting: true, error: null });
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const formData = new FormData(e.currentTarget);
|
|
79
|
+
const result = await submitAction(action, formData, actionName, method);
|
|
80
|
+
|
|
81
|
+
if (result.ok) {
|
|
82
|
+
setState({ submitting: false, error: null });
|
|
83
|
+
onActionSuccess?.(result);
|
|
84
|
+
} else {
|
|
85
|
+
const message = "요청이 실패했습니다.";
|
|
86
|
+
setState({ submitting: false, error: message });
|
|
87
|
+
onActionError?.(new Error(message));
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : "요청 실패";
|
|
91
|
+
setState({ submitting: false, error: message });
|
|
92
|
+
onActionError?.(error instanceof Error ? error : new Error(message));
|
|
93
|
+
} finally {
|
|
94
|
+
submittingRef.current = false;
|
|
95
|
+
}
|
|
96
|
+
}, [action, actionName, enhance, method, onActionSuccess, onActionError]);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<form action={action} method={formMethod} onSubmit={handleSubmit} {...rest}>
|
|
100
|
+
<input type="hidden" name="_action" value={actionName} />
|
|
101
|
+
{method !== "post" && <input type="hidden" name="_method" value={method.toUpperCase()} />}
|
|
102
|
+
{typeof children === "function" ? children(state) : children}
|
|
103
|
+
</form>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useSSE hook and readStreamWithYield utility
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the microtask-starvation-safe ReadableStream reading
|
|
5
|
+
* utilities work correctly.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
|
8
|
+
import { readStreamWithYield } from "../use-sse";
|
|
9
|
+
|
|
10
|
+
// Note: useSSE is a React hook and would need a React testing environment.
|
|
11
|
+
// We test the lower-level readStreamWithYield utility here since it contains
|
|
12
|
+
// the core macrotask-yielding logic that prevents main thread blocking.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Helper: create a ReadableStream from an array of strings
|
|
16
|
+
*/
|
|
17
|
+
function createMockStream(chunks: string[]): ReadableStream<Uint8Array> {
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
let index = 0;
|
|
20
|
+
|
|
21
|
+
return new ReadableStream({
|
|
22
|
+
pull(controller) {
|
|
23
|
+
if (index < chunks.length) {
|
|
24
|
+
controller.enqueue(encoder.encode(chunks[index++]));
|
|
25
|
+
} else {
|
|
26
|
+
controller.close();
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("readStreamWithYield", () => {
|
|
33
|
+
test("should read all chunks from a stream", async () => {
|
|
34
|
+
const chunks = ["Hello", " ", "World"];
|
|
35
|
+
const stream = createMockStream(chunks);
|
|
36
|
+
const received: string[] = [];
|
|
37
|
+
|
|
38
|
+
await readStreamWithYield(stream, {
|
|
39
|
+
onChunk: (text) => received.push(text),
|
|
40
|
+
onDone: () => {},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(received).toEqual(chunks);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("should call onDone when stream completes", async () => {
|
|
47
|
+
const stream = createMockStream(["a", "b"]);
|
|
48
|
+
let doneCalled = false;
|
|
49
|
+
|
|
50
|
+
await readStreamWithYield(stream, {
|
|
51
|
+
onChunk: () => {},
|
|
52
|
+
onDone: () => {
|
|
53
|
+
doneCalled = true;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(doneCalled).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("should call onError on stream failure", async () => {
|
|
61
|
+
const stream = new ReadableStream({
|
|
62
|
+
start(controller) {
|
|
63
|
+
controller.error(new Error("test error"));
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
let errorCaught: Error | null = null;
|
|
68
|
+
|
|
69
|
+
await readStreamWithYield(stream, {
|
|
70
|
+
onChunk: () => {},
|
|
71
|
+
onError: (err) => {
|
|
72
|
+
errorCaught = err;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(errorCaught).not.toBeNull();
|
|
77
|
+
expect(errorCaught!.message).toBe("test error");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("should respect abort signal", async () => {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const chunks = ["a", "b", "c", "d", "e"];
|
|
83
|
+
const stream = createMockStream(chunks);
|
|
84
|
+
const received: string[] = [];
|
|
85
|
+
|
|
86
|
+
// Abort after first chunk
|
|
87
|
+
let chunkCount = 0;
|
|
88
|
+
|
|
89
|
+
await readStreamWithYield(stream, {
|
|
90
|
+
onChunk: (text) => {
|
|
91
|
+
received.push(text);
|
|
92
|
+
chunkCount++;
|
|
93
|
+
if (chunkCount >= 2) {
|
|
94
|
+
controller.abort();
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Should have received at most 2-3 chunks (abort may not be immediate)
|
|
101
|
+
expect(received.length).toBeLessThanOrEqual(3);
|
|
102
|
+
expect(received.length).toBeGreaterThanOrEqual(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("should handle empty stream", async () => {
|
|
106
|
+
const stream = createMockStream([]);
|
|
107
|
+
const received: string[] = [];
|
|
108
|
+
let doneCalled = false;
|
|
109
|
+
|
|
110
|
+
await readStreamWithYield(stream, {
|
|
111
|
+
onChunk: (text) => received.push(text),
|
|
112
|
+
onDone: () => {
|
|
113
|
+
doneCalled = true;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(received).toEqual([]);
|
|
118
|
+
expect(doneCalled).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("should yield between chunks (non-blocking)", async () => {
|
|
122
|
+
// Create a stream with many rapid chunks
|
|
123
|
+
const chunkCount = 50;
|
|
124
|
+
const chunks = Array.from({ length: chunkCount }, (_, i) => `chunk-${i}`);
|
|
125
|
+
const stream = createMockStream(chunks);
|
|
126
|
+
const received: string[] = [];
|
|
127
|
+
|
|
128
|
+
// Track that setTimeout(0) yields are happening by checking
|
|
129
|
+
// that the promise resolves in multiple event loop ticks
|
|
130
|
+
let macrotaskCount = 0;
|
|
131
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
132
|
+
|
|
133
|
+
// Count macrotask yields (setTimeout(0) calls from yieldToMacrotask)
|
|
134
|
+
const timeoutSpy = mock((fn: Function, ms: number) => {
|
|
135
|
+
if (ms === 0) macrotaskCount++;
|
|
136
|
+
return originalSetTimeout(fn, ms);
|
|
137
|
+
});
|
|
138
|
+
// @ts-expect-error - mock override
|
|
139
|
+
globalThis.setTimeout = timeoutSpy;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await readStreamWithYield(stream, {
|
|
143
|
+
onChunk: (text) => received.push(text),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Every chunk should cause a macrotask yield
|
|
147
|
+
expect(received.length).toBe(chunkCount);
|
|
148
|
+
expect(macrotaskCount).toBeGreaterThanOrEqual(chunkCount);
|
|
149
|
+
} finally {
|
|
150
|
+
globalThis.setTimeout = originalSetTimeout;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|