@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.
Files changed (91) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/context.ts +65 -0
  38. package/src/filling/filling.ts +336 -14
  39. package/src/filling/index.ts +5 -1
  40. package/src/filling/session.ts +216 -0
  41. package/src/filling/ws.ts +78 -0
  42. package/src/generator/generate.ts +2 -2
  43. package/src/guard/auto-correct.ts +0 -29
  44. package/src/guard/check.ts +14 -31
  45. package/src/guard/presets/index.ts +296 -294
  46. package/src/guard/rules.ts +15 -19
  47. package/src/guard/validator.ts +834 -834
  48. package/src/index.ts +5 -1
  49. package/src/island/index.ts +373 -304
  50. package/src/kitchen/api/contract-api.ts +225 -0
  51. package/src/kitchen/api/diff-parser.ts +108 -0
  52. package/src/kitchen/api/file-api.ts +273 -0
  53. package/src/kitchen/api/guard-api.ts +83 -0
  54. package/src/kitchen/api/guard-decisions.ts +100 -0
  55. package/src/kitchen/api/routes-api.ts +50 -0
  56. package/src/kitchen/index.ts +21 -0
  57. package/src/kitchen/kitchen-handler.ts +256 -0
  58. package/src/kitchen/kitchen-ui.ts +1732 -0
  59. package/src/kitchen/stream/activity-sse.ts +145 -0
  60. package/src/kitchen/stream/file-tailer.ts +99 -0
  61. package/src/middleware/compress.ts +62 -0
  62. package/src/middleware/cors.ts +47 -0
  63. package/src/middleware/index.ts +10 -0
  64. package/src/middleware/jwt.ts +134 -0
  65. package/src/middleware/logger.ts +58 -0
  66. package/src/middleware/timeout.ts +55 -0
  67. package/src/paths.ts +0 -4
  68. package/src/plugins/hooks.ts +64 -0
  69. package/src/plugins/index.ts +3 -0
  70. package/src/plugins/types.ts +5 -0
  71. package/src/report/build.ts +0 -6
  72. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  73. package/src/router/fs-patterns.ts +11 -1
  74. package/src/router/fs-routes.ts +78 -14
  75. package/src/router/fs-scanner.ts +2 -2
  76. package/src/router/fs-types.ts +2 -1
  77. package/src/runtime/adapter-bun.ts +62 -0
  78. package/src/runtime/adapter.ts +47 -0
  79. package/src/runtime/cache.ts +310 -0
  80. package/src/runtime/handler.ts +65 -0
  81. package/src/runtime/image-handler.ts +195 -0
  82. package/src/runtime/index.ts +12 -0
  83. package/src/runtime/middleware.ts +263 -0
  84. package/src/runtime/server.ts +686 -92
  85. package/src/runtime/ssr.ts +55 -29
  86. package/src/runtime/streaming-ssr.ts +106 -82
  87. package/src/spec/index.ts +0 -1
  88. package/src/spec/schema.ts +1 -0
  89. package/src/testing/index.ts +144 -0
  90. package/src/watcher/watcher.ts +27 -1
  91. package/src/spec/lock.ts +0 -56
@@ -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
- await handleFileChange(next);
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: "connected" | "reload" | "island-update" | "layout-update" | "css-update" | "error" | "ping" | "guard-violation";
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
+ }
@@ -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
- let lock: SpecLock | null = null;
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. Lock 복원 (있는 경우)
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
- // 4. Config Lockfile 복원 (있는 경우)
200
+ // 3. Config Lockfile 복원 (있는 경우)
220
201
  if (snapshot.configSnapshot) {
221
202
  try {
222
203
  await writeLockfile(rootDir, snapshot.configSnapshot.lockfile);
@@ -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
- /** spec.lock.json 내용 (~0.1KB, 없을 있음) */
34
- lock: SpecLock | null;
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
+ });