@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
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Mandu Session Storage
3
+ * 쿠키 기반 서버 사이드 세션 관리
4
+ */
5
+
6
+ import { type CookieManager, type CookieOptions } from "./context";
7
+
8
+ // ========== Types ==========
9
+
10
+ export interface SessionData {
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ export interface SessionStorage {
15
+ /** 요청의 쿠키에서 세션 가져오기 */
16
+ getSession(cookies: CookieManager): Promise<Session>;
17
+ /** 세션을 직렬화하여 Set-Cookie 헤더 문자열 반환 */
18
+ commitSession(session: Session): Promise<string>;
19
+ /** 세션 파기 (쿠키 삭제) */
20
+ destroySession(session: Session): Promise<string>;
21
+ }
22
+
23
+ export interface CookieSessionOptions {
24
+ cookie: {
25
+ /** 쿠키 이름 (기본: "__session") */
26
+ name?: string;
27
+ /** HMAC 서명 시크릿 */
28
+ secrets: string[];
29
+ /** 기본 쿠키 옵션 */
30
+ httpOnly?: boolean;
31
+ secure?: boolean;
32
+ sameSite?: "strict" | "lax" | "none";
33
+ maxAge?: number;
34
+ path?: string;
35
+ domain?: string;
36
+ };
37
+ }
38
+
39
+ // ========== Session Class ==========
40
+
41
+ export class Session {
42
+ private data: SessionData;
43
+ private flash: Map<string, unknown> = new Map();
44
+ readonly id: string;
45
+
46
+ constructor(data: SessionData = {}, id?: string) {
47
+ this.data = { ...data };
48
+ this.id = id ?? crypto.randomUUID();
49
+ }
50
+
51
+ get<T = unknown>(key: string): T | undefined {
52
+ // flash 데이터는 한번 읽으면 제거
53
+ if (this.flash.has(key)) {
54
+ const value = this.flash.get(key);
55
+ this.flash.delete(key);
56
+ return value as T;
57
+ }
58
+ return this.data[key] as T | undefined;
59
+ }
60
+
61
+ set(key: string, value: unknown): void {
62
+ this.data[key] = value;
63
+ }
64
+
65
+ has(key: string): boolean {
66
+ return key in this.data || this.flash.has(key);
67
+ }
68
+
69
+ unset(key: string): void {
70
+ delete this.data[key];
71
+ }
72
+
73
+ /**
74
+ * Flash 메시지 — 다음 요청에서 한번만 읽을 수 있는 데이터
75
+ * 로그인 성공 메시지, 에러 알림 등에 사용
76
+ */
77
+ setFlash(key: string, value: unknown): void {
78
+ this.flash.set(key, value);
79
+ // flash 데이터도 직렬화에 포함
80
+ this.data[`__flash_${key}`] = value;
81
+ }
82
+
83
+ /** 내부 직렬화용 */
84
+ toJSON(): SessionData {
85
+ return { ...this.data };
86
+ }
87
+
88
+ /** flash 데이터 복원 */
89
+ static fromJSON(data: SessionData): Session {
90
+ const session = new Session();
91
+ const flashKeys: string[] = [];
92
+
93
+ for (const [key, value] of Object.entries(data)) {
94
+ if (key.startsWith("__flash_")) {
95
+ const realKey = key.slice(8);
96
+ session.flash.set(realKey, value);
97
+ flashKeys.push(key);
98
+ } else {
99
+ session.data[key] = value;
100
+ }
101
+ }
102
+
103
+ // flash 키는 data에서 제거 (한번 복원되면 끝)
104
+ for (const key of flashKeys) {
105
+ delete session.data[key];
106
+ }
107
+
108
+ return session;
109
+ }
110
+ }
111
+
112
+ // ========== Cookie Session Storage ==========
113
+
114
+ /**
115
+ * 쿠키 기반 세션 스토리지 생성
116
+ *
117
+ * @example
118
+ * ```typescript
119
+ * import { createCookieSessionStorage } from "@mandujs/core";
120
+ *
121
+ * const sessionStorage = createCookieSessionStorage({
122
+ * cookie: {
123
+ * name: "__session",
124
+ * secrets: [process.env.SESSION_SECRET!],
125
+ * httpOnly: true,
126
+ * secure: true,
127
+ * sameSite: "lax",
128
+ * maxAge: 60 * 60 * 24, // 1일
129
+ * },
130
+ * });
131
+ *
132
+ * // filling에서 사용
133
+ * .action("login", async (ctx) => {
134
+ * const session = await sessionStorage.getSession(ctx.cookies);
135
+ * session.set("userId", user.id);
136
+ * session.setFlash("message", "로그인 성공!");
137
+ * const setCookie = await sessionStorage.commitSession(session);
138
+ * return ctx.redirect("/dashboard", {
139
+ * headers: { "Set-Cookie": setCookie },
140
+ * });
141
+ * });
142
+ * ```
143
+ */
144
+ export function createCookieSessionStorage(options: CookieSessionOptions): SessionStorage {
145
+ const {
146
+ name = "__session",
147
+ secrets,
148
+ httpOnly = true,
149
+ secure = process.env.NODE_ENV === "production",
150
+ sameSite = "lax",
151
+ maxAge = 86400,
152
+ path = "/",
153
+ domain,
154
+ } = options.cookie;
155
+
156
+ if (!secrets.length) {
157
+ throw new Error("[Mandu Session] At least one secret is required");
158
+ }
159
+
160
+ const cookieOptions: CookieOptions = {
161
+ httpOnly,
162
+ secure,
163
+ sameSite,
164
+ maxAge,
165
+ path,
166
+ domain,
167
+ };
168
+
169
+ return {
170
+ async getSession(cookies: CookieManager): Promise<Session> {
171
+ // Secret rotation: 모든 시크릿으로 검증 시도 (서명은 항상 secrets[0]으로)
172
+ for (const secret of secrets) {
173
+ const raw = await cookies.getSigned(name, secret);
174
+ if (typeof raw === "string" && raw.length > 0) {
175
+ try {
176
+ const data = JSON.parse(raw) as SessionData;
177
+ return Session.fromJSON(data);
178
+ } catch {
179
+ continue;
180
+ }
181
+ }
182
+ }
183
+ return new Session();
184
+ },
185
+
186
+ async commitSession(session: Session): Promise<string> {
187
+ const value = JSON.stringify(session.toJSON());
188
+ // 서명된 쿠키로 직렬화
189
+ const encoder = new TextEncoder();
190
+ const key = await crypto.subtle.importKey(
191
+ "raw",
192
+ encoder.encode(secrets[0]),
193
+ { name: "HMAC", hash: "SHA-256" },
194
+ false,
195
+ ["sign"]
196
+ );
197
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
198
+ const sigBase64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=+$/, "");
199
+
200
+ const cookieValue = `${value}.${sigBase64}`;
201
+ const parts = [`${name}=${encodeURIComponent(cookieValue)}`];
202
+ if (cookieOptions.path) parts.push(`Path=${cookieOptions.path}`);
203
+ if (cookieOptions.domain) parts.push(`Domain=${cookieOptions.domain}`);
204
+ if (cookieOptions.maxAge) parts.push(`Max-Age=${cookieOptions.maxAge}`);
205
+ if (cookieOptions.httpOnly) parts.push("HttpOnly");
206
+ if (cookieOptions.secure) parts.push("Secure");
207
+ if (cookieOptions.sameSite) parts.push(`SameSite=${cookieOptions.sameSite}`);
208
+
209
+ return parts.join("; ");
210
+ },
211
+
212
+ async destroySession(_session: Session): Promise<string> {
213
+ return `${name}=; Path=${path}; Max-Age=0; HttpOnly${secure ? "; Secure" : ""}`;
214
+ },
215
+ };
216
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Mandu WebSocket Handler
3
+ * filling.ws({ open, message, close }) 패턴
4
+ */
5
+
6
+ // ========== Types ==========
7
+
8
+ export interface ManduWebSocket {
9
+ /** 고유 연결 ID */
10
+ readonly id: string;
11
+ /** 연결에 첨부된 데이터 */
12
+ readonly data: Record<string, unknown>;
13
+
14
+ /** 메시지 전송 */
15
+ send(data: string | ArrayBuffer | Uint8Array): void;
16
+ /** 토픽 구독 (pub/sub) */
17
+ subscribe(topic: string): void;
18
+ /** 토픽 구독 해제 */
19
+ unsubscribe(topic: string): void;
20
+ /** 토픽에 브로드캐스트 (자신 제외) */
21
+ publish(topic: string, data: string | ArrayBuffer | Uint8Array): void;
22
+ /** 연결 종료 */
23
+ close(code?: number, reason?: string): void;
24
+ /** JSON 전송 헬퍼 */
25
+ sendJSON(data: unknown): void;
26
+ }
27
+
28
+ export interface WSHandlers {
29
+ /** 연결 시 */
30
+ open?(ws: ManduWebSocket): void;
31
+ /** 메시지 수신 시 */
32
+ message?(ws: ManduWebSocket, message: string | ArrayBuffer): void;
33
+ /** 연결 종료 시 */
34
+ close?(ws: ManduWebSocket, code: number, reason: string): void;
35
+ /** 백프레셔 해소 시 */
36
+ drain?(ws: ManduWebSocket): void;
37
+ }
38
+
39
+ export interface WSUpgradeData {
40
+ routeId: string;
41
+ params: Record<string, string>;
42
+ id: string;
43
+ }
44
+
45
+ // ========== Implementation ==========
46
+
47
+ /**
48
+ * Bun WebSocket을 ManduWebSocket으로 래핑
49
+ */
50
+ export function wrapBunWebSocket(
51
+ bunWs: { send: Function; subscribe: Function; unsubscribe: Function; publish: Function; close: Function; data: unknown }
52
+ ): ManduWebSocket {
53
+ const wsData = bunWs.data as WSUpgradeData;
54
+
55
+ return {
56
+ get id() { return wsData.id; },
57
+ get data() { return wsData as unknown as Record<string, unknown>; },
58
+
59
+ send(data: string | ArrayBuffer | Uint8Array) {
60
+ bunWs.send(data);
61
+ },
62
+ subscribe(topic: string) {
63
+ bunWs.subscribe(topic);
64
+ },
65
+ unsubscribe(topic: string) {
66
+ bunWs.unsubscribe(topic);
67
+ },
68
+ publish(topic: string, data: string | ArrayBuffer | Uint8Array) {
69
+ bunWs.publish(topic, data);
70
+ },
71
+ close(code?: number, reason?: string) {
72
+ bunWs.close(code, reason);
73
+ },
74
+ sendJSON(data: unknown) {
75
+ bunWs.send(JSON.stringify(data));
76
+ },
77
+ };
78
+ }
@@ -1,7 +1,7 @@
1
1
  import type { RoutesManifest, RouteSpec } from "../spec/schema";
2
2
  import { generateApiHandler, generatePageComponent, generateSlotLogic } from "./templates";
3
3
  import { generateContractTypeGlue, generateContractTemplate, generateContractTypesIndex } from "./contract-glue";
4
- import { computeHash } from "../spec/lock";
4
+ import { createHash } from "crypto";
5
5
  import { getWatcher } from "../watcher/watcher";
6
6
  import { resolveGeneratedPaths, GENERATED_RELATIVE_PATHS } from "../paths";
7
7
  import path from "path";
@@ -158,7 +158,7 @@ export async function generateRoutes(
158
158
  generatedAt: new Date().toISOString(),
159
159
  specSource: {
160
160
  path: ".mandu/routes.manifest.json",
161
- hash: computeHash(manifest),
161
+ hash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"),
162
162
  },
163
163
  files: {},
164
164
  frameworkPaths: [
@@ -2,7 +2,6 @@ import type { RoutesManifest } from "../spec/schema";
2
2
  import type { GuardViolation } from "./rules";
3
3
  import { GUARD_RULES } from "./rules";
4
4
  import { runGuardCheck } from "./check";
5
- import { writeLock } from "../spec/lock";
6
5
  import { generateRoutes } from "../generator/generate";
7
6
  import { beginChange, commitChange, rollbackChange } from "../change";
8
7
  import path from "path";
@@ -27,7 +26,6 @@ export interface AutoCorrectResult {
27
26
 
28
27
  // 자동 수정 가능한 규칙들
29
28
  const AUTO_CORRECTABLE_RULES = new Set([
30
- GUARD_RULES.SPEC_HASH_MISMATCH.id,
31
29
  GUARD_RULES.GENERATED_MANUAL_EDIT.id,
32
30
  GUARD_RULES.SLOT_NOT_FOUND.id,
33
31
  ]);
@@ -148,9 +146,6 @@ async function correctViolation(
148
146
  rootDir: string
149
147
  ): Promise<AutoCorrectStep> {
150
148
  switch (violation.ruleId) {
151
- case GUARD_RULES.SPEC_HASH_MISMATCH.id:
152
- return await correctSpecHashMismatch(manifest, rootDir);
153
-
154
149
  case GUARD_RULES.GENERATED_MANUAL_EDIT.id:
155
150
  return await correctGeneratedManualEdit(manifest, rootDir);
156
151
 
@@ -167,30 +162,6 @@ async function correctViolation(
167
162
  }
168
163
  }
169
164
 
170
- async function correctSpecHashMismatch(
171
- manifest: RoutesManifest,
172
- rootDir: string
173
- ): Promise<AutoCorrectStep> {
174
- try {
175
- const lockPath = path.join(rootDir, ".mandu/spec.lock.json");
176
- await writeLock(lockPath, manifest);
177
-
178
- return {
179
- ruleId: GUARD_RULES.SPEC_HASH_MISMATCH.id,
180
- action: "spec-upsert",
181
- success: true,
182
- message: "spec.lock.json 업데이트 완료",
183
- };
184
- } catch (error) {
185
- return {
186
- ruleId: GUARD_RULES.SPEC_HASH_MISMATCH.id,
187
- action: "spec-upsert",
188
- success: false,
189
- message: `spec.lock.json 업데이트 실패: ${error instanceof Error ? error.message : String(error)}`,
190
- };
191
- }
192
- }
193
-
194
165
  async function correctGeneratedManualEdit(
195
166
  manifest: RoutesManifest,
196
167
  rootDir: string
@@ -1,5 +1,4 @@
1
1
  import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules";
2
- import { verifyLock, computeHash } from "../spec/lock";
3
2
  import { runContractGuardCheck } from "./contract-guard";
4
3
  import { validateSlotContent } from "../slot/validator";
5
4
  import type { RoutesManifest } from "../spec/schema";
@@ -59,27 +58,6 @@ async function readFileContent(filePath: string): Promise<string | null> {
59
58
  }
60
59
  }
61
60
 
62
- // Rule 1: Spec hash mismatch
63
- export async function checkSpecHashMismatch(
64
- manifest: RoutesManifest,
65
- lockPath: string
66
- ): Promise<GuardViolation | null> {
67
- const result = await verifyLock(lockPath, manifest);
68
-
69
- if (!result.valid) {
70
- return {
71
- ruleId: GUARD_RULES.SPEC_HASH_MISMATCH.id,
72
- file: lockPath,
73
- message: result.lockHash
74
- ? `Spec 해시 불일치: lock(${result.lockHash.slice(0, 8)}...) != current(${result.currentHash.slice(0, 8)}...)`
75
- : "spec.lock.json 파일이 없습니다",
76
- suggestion: "bunx mandu spec-upsert를 실행하여 변경사항을 반영하세요",
77
- };
78
- }
79
-
80
- return null;
81
- }
82
-
83
61
  // Rule 2: Generated file manual edit detection
84
62
  export async function checkGeneratedManualEdit(
85
63
  rootDir: string,
@@ -247,16 +225,25 @@ export async function checkIslandFirstIntegrity(
247
225
  continue;
248
226
  }
249
227
 
250
- // 2. componentModule이 island import하는지 확인
251
- if (route.componentModule) {
228
+ // 2. Island-First integrity: verify that a .island.tsx or .client.tsx file
229
+ // exists alongside the page's componentModule. The page does NOT need to
230
+ // directly import the island - the framework auto-links them via
231
+ // data-island attributes and the manifest's clientModule field.
232
+ if (route.componentModule && route.clientModule) {
252
233
  const componentPath = path.join(rootDir, route.componentModule);
253
234
  const content = await readFileContent(componentPath);
254
- if (content && !content.includes("islandModule") && !content.includes("Island-First")) {
235
+ // Check if the island file actually exists on disk
236
+ const clientPath = path.join(rootDir, route.clientModule);
237
+ const clientExists = await fileExists(clientPath);
238
+ if (content && !clientExists && !content.includes("data-island") && !content.includes("data-mandu-island")) {
255
239
  violations.push({
256
240
  ruleId: "ISLAND_FIRST_INTEGRITY",
257
241
  file: route.componentModule,
258
- message: `componentModule이 island import하지 않습니다 (routeId: ${route.id})`,
259
- suggestion: "mandu generate를 실행하여 Island-First 템플릿으로 재생성하세요",
242
+ message: `No island file found for page route (routeId: ${route.id}). The clientModule '${route.clientModule}' does not exist.`,
243
+ suggestion:
244
+ "Create a .island.tsx file in the same app/ directory as page.tsx. " +
245
+ "The page should reference islands via data-island attributes, NOT by directly importing or re-exporting the island. " +
246
+ "Importing island() return values into page.tsx causes a runtime crash because they are config objects, not React components.",
260
247
  });
261
248
  }
262
249
  }
@@ -370,20 +357,17 @@ export async function runGuardCheck(
370
357
  ): Promise<GuardCheckResult> {
371
358
  const config = await loadManduConfig(rootDir);
372
359
 
373
- const lockPath = path.join(rootDir, ".mandu/spec.lock.json");
374
360
  const mapPath = path.join(rootDir, ".mandu/generated/generated.map.json");
375
361
 
376
362
  // ============================================
377
363
  // Phase 1: 독립적인 검사 병렬 실행
378
364
  // ============================================
379
365
  const [
380
- hashViolation,
381
366
  importViolations,
382
367
  slotViolations,
383
368
  specDirViolations,
384
369
  islandViolations,
385
370
  ] = await Promise.all([
386
- checkSpecHashMismatch(manifest, lockPath),
387
371
  checkInvalidGeneratedImport(rootDir),
388
372
  checkSlotFileExists(manifest, rootDir),
389
373
  checkSpecDirNaming(rootDir),
@@ -391,7 +375,6 @@ export async function runGuardCheck(
391
375
  ]);
392
376
 
393
377
  const violations: GuardViolation[] = [];
394
- if (hashViolation) violations.push(hashViolation);
395
378
  violations.push(...importViolations);
396
379
  violations.push(...slotViolations);
397
380
  violations.push(...specDirViolations);