@mandujs/core 0.9.9 → 0.9.11

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.
@@ -1,3 +1,3 @@
1
- export * from "./generate";
2
- export * from "./templates";
3
- export * from "./contract-glue";
1
+ export * from "./generate";
2
+ export * from "./templates";
3
+ export * from "./contract-glue";
@@ -189,13 +189,18 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
189
189
  }
190
190
 
191
191
  export function generatePageComponent(route: RouteSpec): string {
192
- const pageName = toPascalCase(route.id);
192
+ // Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용
193
+ if (route.clientModule) {
194
+ return generatePageComponentWithIsland(route);
195
+ }
193
196
 
194
197
  // slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
195
198
  if (route.slotModule) {
196
199
  return generatePageHandlerWithSlot(route);
197
200
  }
198
201
 
202
+ const pageName = toPascalCase(route.id);
203
+
199
204
  // slotModule이 없으면 기존 방식
200
205
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
201
206
  // Route ID: ${route.id}
@@ -218,6 +223,73 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
218
223
  `;
219
224
  }
220
225
 
226
+ /**
227
+ * Island-First Rendering: SSR이 island의 render 함수를 직접 사용
228
+ * - clientModule의 definition.setup() + definition.render() 호출
229
+ * - SSR과 클라이언트가 동일한 렌더링 로직 사용 → 불일치 구조적 방지
230
+ * - slotModule 유무에 따라 두 가지 변형 생성
231
+ */
232
+ export function generatePageComponentWithIsland(route: RouteSpec): string {
233
+ const pageName = toPascalCase(route.id);
234
+ const clientImportPath = computeSlotImportPath(route.clientModule!, "apps/web/generated/routes");
235
+
236
+ // clientModule + slotModule → PageRegistration 형식
237
+ if (route.slotModule) {
238
+ const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/web/generated/routes");
239
+
240
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
241
+ // Island-First Rendering + Slot Module
242
+ // Route ID: ${route.id}
243
+ // Pattern: ${route.pattern}
244
+ // Client Module: ${route.clientModule}
245
+ // Slot Module: ${route.slotModule}
246
+
247
+ import React from "react";
248
+ import filling from "${slotImportPath}";
249
+ import islandModule from "${clientImportPath}";
250
+
251
+ interface Props {
252
+ params: Record<string, string>;
253
+ loaderData?: unknown;
254
+ }
255
+
256
+ function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
257
+ const serverData = (loaderData || {}) as any;
258
+ const setupResult = islandModule.definition.setup(serverData);
259
+ return islandModule.definition.render(setupResult) as React.ReactElement;
260
+ }
261
+
262
+ // PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
263
+ export default {
264
+ component: ${pageName}Page,
265
+ filling: filling,
266
+ };
267
+ `;
268
+ }
269
+
270
+ // clientModule만 (slotModule 없음) → default export 컴포넌트
271
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
272
+ // Island-First Rendering: SSR이 island render 직접 사용
273
+ // Route ID: ${route.id}
274
+ // Pattern: ${route.pattern}
275
+ // Client Module: ${route.clientModule}
276
+
277
+ import React from "react";
278
+ import islandModule from "${clientImportPath}";
279
+
280
+ interface Props {
281
+ params: Record<string, string>;
282
+ loaderData?: unknown;
283
+ }
284
+
285
+ export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
286
+ const serverData = (loaderData || {}) as any;
287
+ const setupResult = islandModule.definition.setup(serverData);
288
+ return islandModule.definition.render(setupResult) as React.ReactElement;
289
+ }
290
+ `;
291
+ }
292
+
221
293
  /**
222
294
  * slotModule이 있는 Page Route용 Handler 생성
223
295
  * - component와 filling을 함께 export
@@ -193,6 +193,46 @@ export async function checkSlotContentValidation(
193
193
  return violations;
194
194
  }
195
195
 
196
+ // Rule: Island-First Integrity
197
+ export async function checkIslandFirstIntegrity(
198
+ manifest: RoutesManifest,
199
+ rootDir: string
200
+ ): Promise<GuardViolation[]> {
201
+ const violations: GuardViolation[] = [];
202
+
203
+ for (const route of manifest.routes) {
204
+ if (route.kind !== "page" || !route.clientModule) continue;
205
+
206
+ // 1. clientModule 파일 존재 여부
207
+ const clientPath = path.join(rootDir, route.clientModule);
208
+ if (!(await fileExists(clientPath))) {
209
+ violations.push({
210
+ ruleId: "CLIENT_MODULE_NOT_FOUND",
211
+ file: route.clientModule,
212
+ message: `clientModule 파일을 찾을 수 없습니다 (routeId: ${route.id})`,
213
+ suggestion: "clientModule 경로를 확인하거나 파일을 생성하세요",
214
+ });
215
+ continue;
216
+ }
217
+
218
+ // 2. componentModule이 island을 import하는지 확인
219
+ if (route.componentModule) {
220
+ const componentPath = path.join(rootDir, route.componentModule);
221
+ const content = await readFileContent(componentPath);
222
+ if (content && !content.includes("islandModule") && !content.includes("Island-First")) {
223
+ violations.push({
224
+ ruleId: "ISLAND_FIRST_INTEGRITY",
225
+ file: route.componentModule,
226
+ message: `componentModule이 island을 import하지 않습니다 (routeId: ${route.id})`,
227
+ suggestion: "mandu generate를 실행하여 Island-First 템플릿으로 재생성하세요",
228
+ });
229
+ }
230
+ }
231
+ }
232
+
233
+ return violations;
234
+ }
235
+
196
236
  // Rule 4: Forbidden imports in generated files
197
237
  export async function checkForbiddenImportsInGenerated(
198
238
  rootDir: string,
@@ -303,6 +343,10 @@ export async function runGuardCheck(
303
343
  const contractViolations = await runContractGuardCheck(manifest, rootDir);
304
344
  violations.push(...contractViolations);
305
345
 
346
+ // Rule: Island-First Integrity
347
+ const islandViolations = await checkIslandFirstIntegrity(manifest, rootDir);
348
+ violations.push(...islandViolations);
349
+
306
350
  return {
307
351
  passed: violations.length === 0,
308
352
  violations,
@@ -95,6 +95,19 @@ export const GUARD_RULES: Record<string, GuardRule> = {
95
95
  description: "Slot에 구현된 메서드가 Contract에 문서화되지 않았습니다",
96
96
  severity: "warning",
97
97
  },
98
+ // Island-First Rendering rules
99
+ ISLAND_FIRST_INTEGRITY: {
100
+ id: "ISLAND_FIRST_INTEGRITY",
101
+ name: "Island-First Integrity",
102
+ description: "clientModule이 있는 page route의 componentModule이 island을 import하지 않습니다",
103
+ severity: "error",
104
+ },
105
+ CLIENT_MODULE_NOT_FOUND: {
106
+ id: "CLIENT_MODULE_NOT_FOUND",
107
+ name: "Client Module Not Found",
108
+ description: "spec에 명시된 clientModule 파일을 찾을 수 없습니다",
109
+ severity: "error",
110
+ },
98
111
  };
99
112
 
100
113
  export const FORBIDDEN_IMPORTS = ["fs", "child_process", "cluster", "worker_threads"];
@@ -1 +1 @@
1
- export * from "./build";
1
+ export * from "./build";
@@ -1,222 +1,222 @@
1
- /**
2
- * Mandu Middleware Compose 🔗
3
- * Hono 스타일 미들웨어 조합 패턴
4
- *
5
- * @see https://github.com/honojs/hono/blob/main/src/compose.ts
6
- */
7
-
8
- import type { ManduContext } from "../filling/context";
9
-
10
- /**
11
- * Next 함수 타입
12
- */
13
- export type Next = () => Promise<void>;
14
-
15
- /**
16
- * 미들웨어 함수 타입
17
- * - Response 반환: 체인 중단 (Guard 역할)
18
- * - void 반환: 다음 미들웨어 실행
19
- */
20
- export type Middleware = (
21
- ctx: ManduContext,
22
- next: Next
23
- ) => Response | void | Promise<Response | void>;
24
-
25
- /**
26
- * 에러 핸들러 타입
27
- */
28
- export type ErrorHandler = (
29
- error: Error,
30
- ctx: ManduContext
31
- ) => Response | Promise<Response>;
32
-
33
- /**
34
- * NotFound 핸들러 타입
35
- */
36
- export type NotFoundHandler = (ctx: ManduContext) => Response | Promise<Response>;
37
-
38
- /**
39
- * 미들웨어 엔트리 (메타데이터 포함)
40
- */
41
- export interface MiddlewareEntry {
42
- fn: Middleware;
43
- name?: string;
44
- isAsync?: boolean;
45
- }
46
-
47
- /**
48
- * Compose 옵션
49
- */
50
- export interface ComposeOptions {
51
- onError?: ErrorHandler;
52
- onNotFound?: NotFoundHandler;
53
- }
54
-
55
- /**
56
- * 미들웨어 함수들을 하나의 실행 함수로 조합
57
- *
58
- * @example
59
- * ```typescript
60
- * const middleware = [
61
- * { fn: async (ctx, next) => { console.log('before'); await next(); console.log('after'); } },
62
- * { fn: async (ctx, next) => { return ctx.ok({ data: 'hello' }); } },
63
- * ];
64
- *
65
- * const handler = compose(middleware, {
66
- * onError: (err, ctx) => ctx.json({ error: err.message }, 500),
67
- * onNotFound: (ctx) => ctx.notFound(),
68
- * });
69
- *
70
- * const response = await handler(context);
71
- * ```
72
- */
73
- export function compose(
74
- middleware: MiddlewareEntry[],
75
- options: ComposeOptions = {}
76
- ): (ctx: ManduContext) => Promise<Response> {
77
- const { onError, onNotFound } = options;
78
-
79
- return async (ctx: ManduContext): Promise<Response> => {
80
- let index = -1;
81
- let finalResponse: Response | undefined;
82
-
83
- /**
84
- * 미들웨어 순차 실행
85
- * @param i 현재 인덱스
86
- */
87
- async function dispatch(i: number): Promise<void> {
88
- // next() 이중 호출 방지
89
- if (i <= index) {
90
- throw new Error("next() called multiple times");
91
- }
92
- index = i;
93
-
94
- const entry = middleware[i];
95
-
96
- if (!entry) {
97
- // 모든 미들웨어 통과 후 핸들러 없음
98
- if (!finalResponse && onNotFound) {
99
- finalResponse = await onNotFound(ctx);
100
- }
101
- return;
102
- }
103
-
104
- try {
105
- const result = await entry.fn(ctx, () => dispatch(i + 1));
106
-
107
- // Response 반환 시 체인 중단
108
- if (result instanceof Response) {
109
- finalResponse = result;
110
- return;
111
- }
112
- } catch (err) {
113
- if (err instanceof Error && onError) {
114
- finalResponse = await onError(err, ctx);
115
- return;
116
- }
117
- throw err;
118
- }
119
- }
120
-
121
- await dispatch(0);
122
-
123
- // 응답이 없으면 404
124
- if (!finalResponse) {
125
- if (onNotFound) {
126
- finalResponse = await onNotFound(ctx);
127
- } else {
128
- finalResponse = new Response("Not Found", { status: 404 });
129
- }
130
- }
131
-
132
- return finalResponse;
133
- };
134
- }
135
-
136
- /**
137
- * 미들웨어 배열 생성 헬퍼
138
- *
139
- * @example
140
- * ```typescript
141
- * const mw = createMiddleware([
142
- * authGuard,
143
- * rateLimitGuard,
144
- * mainHandler,
145
- * ]);
146
- * ```
147
- */
148
- export function createMiddleware(
149
- fns: Middleware[]
150
- ): MiddlewareEntry[] {
151
- return fns.map((fn, i) => ({
152
- fn,
153
- name: fn.name || `middleware_${i}`,
154
- isAsync: fn.constructor.name === "AsyncFunction",
155
- }));
156
- }
157
-
158
- /**
159
- * 미들웨어 체인 빌더
160
- *
161
- * @example
162
- * ```typescript
163
- * const chain = new MiddlewareChain()
164
- * .use(authGuard)
165
- * .use(rateLimitGuard)
166
- * .use(mainHandler)
167
- * .onError((err, ctx) => ctx.json({ error: err.message }, 500))
168
- * .build();
169
- *
170
- * const response = await chain(ctx);
171
- * ```
172
- */
173
- export class MiddlewareChain {
174
- private middleware: MiddlewareEntry[] = [];
175
- private errorHandler?: ErrorHandler;
176
- private notFoundHandler?: NotFoundHandler;
177
-
178
- /**
179
- * 미들웨어 추가
180
- */
181
- use(fn: Middleware, name?: string): this {
182
- this.middleware.push({
183
- fn,
184
- name: name || fn.name || `middleware_${this.middleware.length}`,
185
- isAsync: fn.constructor.name === "AsyncFunction",
186
- });
187
- return this;
188
- }
189
-
190
- /**
191
- * 에러 핸들러 설정
192
- */
193
- onError(handler: ErrorHandler): this {
194
- this.errorHandler = handler;
195
- return this;
196
- }
197
-
198
- /**
199
- * NotFound 핸들러 설정
200
- */
201
- onNotFound(handler: NotFoundHandler): this {
202
- this.notFoundHandler = handler;
203
- return this;
204
- }
205
-
206
- /**
207
- * 미들웨어 체인 빌드
208
- */
209
- build(): (ctx: ManduContext) => Promise<Response> {
210
- return compose(this.middleware, {
211
- onError: this.errorHandler,
212
- onNotFound: this.notFoundHandler,
213
- });
214
- }
215
-
216
- /**
217
- * 미들웨어 목록 조회
218
- */
219
- getMiddleware(): MiddlewareEntry[] {
220
- return [...this.middleware];
221
- }
222
- }
1
+ /**
2
+ * Mandu Middleware Compose 🔗
3
+ * Hono 스타일 미들웨어 조합 패턴
4
+ *
5
+ * @see https://github.com/honojs/hono/blob/main/src/compose.ts
6
+ */
7
+
8
+ import type { ManduContext } from "../filling/context";
9
+
10
+ /**
11
+ * Next 함수 타입
12
+ */
13
+ export type Next = () => Promise<void>;
14
+
15
+ /**
16
+ * 미들웨어 함수 타입
17
+ * - Response 반환: 체인 중단 (Guard 역할)
18
+ * - void 반환: 다음 미들웨어 실행
19
+ */
20
+ export type Middleware = (
21
+ ctx: ManduContext,
22
+ next: Next
23
+ ) => Response | void | Promise<Response | void>;
24
+
25
+ /**
26
+ * 에러 핸들러 타입
27
+ */
28
+ export type ErrorHandler = (
29
+ error: Error,
30
+ ctx: ManduContext
31
+ ) => Response | Promise<Response>;
32
+
33
+ /**
34
+ * NotFound 핸들러 타입
35
+ */
36
+ export type NotFoundHandler = (ctx: ManduContext) => Response | Promise<Response>;
37
+
38
+ /**
39
+ * 미들웨어 엔트리 (메타데이터 포함)
40
+ */
41
+ export interface MiddlewareEntry {
42
+ fn: Middleware;
43
+ name?: string;
44
+ isAsync?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Compose 옵션
49
+ */
50
+ export interface ComposeOptions {
51
+ onError?: ErrorHandler;
52
+ onNotFound?: NotFoundHandler;
53
+ }
54
+
55
+ /**
56
+ * 미들웨어 함수들을 하나의 실행 함수로 조합
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * const middleware = [
61
+ * { fn: async (ctx, next) => { console.log('before'); await next(); console.log('after'); } },
62
+ * { fn: async (ctx, next) => { return ctx.ok({ data: 'hello' }); } },
63
+ * ];
64
+ *
65
+ * const handler = compose(middleware, {
66
+ * onError: (err, ctx) => ctx.json({ error: err.message }, 500),
67
+ * onNotFound: (ctx) => ctx.notFound(),
68
+ * });
69
+ *
70
+ * const response = await handler(context);
71
+ * ```
72
+ */
73
+ export function compose(
74
+ middleware: MiddlewareEntry[],
75
+ options: ComposeOptions = {}
76
+ ): (ctx: ManduContext) => Promise<Response> {
77
+ const { onError, onNotFound } = options;
78
+
79
+ return async (ctx: ManduContext): Promise<Response> => {
80
+ let index = -1;
81
+ let finalResponse: Response | undefined;
82
+
83
+ /**
84
+ * 미들웨어 순차 실행
85
+ * @param i 현재 인덱스
86
+ */
87
+ async function dispatch(i: number): Promise<void> {
88
+ // next() 이중 호출 방지
89
+ if (i <= index) {
90
+ throw new Error("next() called multiple times");
91
+ }
92
+ index = i;
93
+
94
+ const entry = middleware[i];
95
+
96
+ if (!entry) {
97
+ // 모든 미들웨어 통과 후 핸들러 없음
98
+ if (!finalResponse && onNotFound) {
99
+ finalResponse = await onNotFound(ctx);
100
+ }
101
+ return;
102
+ }
103
+
104
+ try {
105
+ const result = await entry.fn(ctx, () => dispatch(i + 1));
106
+
107
+ // Response 반환 시 체인 중단
108
+ if (result instanceof Response) {
109
+ finalResponse = result;
110
+ return;
111
+ }
112
+ } catch (err) {
113
+ if (err instanceof Error && onError) {
114
+ finalResponse = await onError(err, ctx);
115
+ return;
116
+ }
117
+ throw err;
118
+ }
119
+ }
120
+
121
+ await dispatch(0);
122
+
123
+ // 응답이 없으면 404
124
+ if (!finalResponse) {
125
+ if (onNotFound) {
126
+ finalResponse = await onNotFound(ctx);
127
+ } else {
128
+ finalResponse = new Response("Not Found", { status: 404 });
129
+ }
130
+ }
131
+
132
+ return finalResponse;
133
+ };
134
+ }
135
+
136
+ /**
137
+ * 미들웨어 배열 생성 헬퍼
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * const mw = createMiddleware([
142
+ * authGuard,
143
+ * rateLimitGuard,
144
+ * mainHandler,
145
+ * ]);
146
+ * ```
147
+ */
148
+ export function createMiddleware(
149
+ fns: Middleware[]
150
+ ): MiddlewareEntry[] {
151
+ return fns.map((fn, i) => ({
152
+ fn,
153
+ name: fn.name || `middleware_${i}`,
154
+ isAsync: fn.constructor.name === "AsyncFunction",
155
+ }));
156
+ }
157
+
158
+ /**
159
+ * 미들웨어 체인 빌더
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const chain = new MiddlewareChain()
164
+ * .use(authGuard)
165
+ * .use(rateLimitGuard)
166
+ * .use(mainHandler)
167
+ * .onError((err, ctx) => ctx.json({ error: err.message }, 500))
168
+ * .build();
169
+ *
170
+ * const response = await chain(ctx);
171
+ * ```
172
+ */
173
+ export class MiddlewareChain {
174
+ private middleware: MiddlewareEntry[] = [];
175
+ private errorHandler?: ErrorHandler;
176
+ private notFoundHandler?: NotFoundHandler;
177
+
178
+ /**
179
+ * 미들웨어 추가
180
+ */
181
+ use(fn: Middleware, name?: string): this {
182
+ this.middleware.push({
183
+ fn,
184
+ name: name || fn.name || `middleware_${this.middleware.length}`,
185
+ isAsync: fn.constructor.name === "AsyncFunction",
186
+ });
187
+ return this;
188
+ }
189
+
190
+ /**
191
+ * 에러 핸들러 설정
192
+ */
193
+ onError(handler: ErrorHandler): this {
194
+ this.errorHandler = handler;
195
+ return this;
196
+ }
197
+
198
+ /**
199
+ * NotFound 핸들러 설정
200
+ */
201
+ onNotFound(handler: NotFoundHandler): this {
202
+ this.notFoundHandler = handler;
203
+ return this;
204
+ }
205
+
206
+ /**
207
+ * 미들웨어 체인 빌드
208
+ */
209
+ build(): (ctx: ManduContext) => Promise<Response> {
210
+ return compose(this.middleware, {
211
+ onError: this.errorHandler,
212
+ onNotFound: this.notFoundHandler,
213
+ });
214
+ }
215
+
216
+ /**
217
+ * 미들웨어 목록 조회
218
+ */
219
+ getMiddleware(): MiddlewareEntry[] {
220
+ return [...this.middleware];
221
+ }
222
+ }
@@ -3,6 +3,6 @@ export * from "./router";
3
3
  export * from "./server";
4
4
  export * from "./cors";
5
5
  export * from "./env";
6
- export * from "./compose";
7
- export * from "./lifecycle";
8
- export * from "./trace";
6
+ export * from "./compose";
7
+ export * from "./lifecycle";
8
+ export * from "./trace";