@mandujs/core 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Mandu Auth Guards - 인증/인가 헬퍼 🔐
3
3
  *
4
- * Guard에서 사용할 수 있는 타입-안전 인증 헬퍼
5
- * 인증 실패 시 적절한 에러를 throw하여 Guard 체인 중단
4
+ * beforeHandle에서 사용할 수 있는 타입-안전 인증 헬퍼
5
+ * 인증 실패 시 적절한 에러를 throw하여 체인 중단
6
6
  */
7
7
 
8
8
  import type { ManduContext } from "./context";
@@ -57,12 +57,12 @@ export interface UserWithRoles extends BaseUser {
57
57
  }
58
58
 
59
59
  // ============================================
60
- // 🔐 Auth Guard Helpers
60
+ // 🔐 Auth Helpers
61
61
  // ============================================
62
62
 
63
63
  /**
64
64
  * 인증된 사용자 필수
65
- * Guard에서 user가 없으면 AuthenticationError throw
65
+ * beforeHandle에서 user가 없으면 AuthenticationError throw
66
66
  *
67
67
  * @param ctx ManduContext
68
68
  * @param key store에서 user를 찾을 키 (기본: 'user')
@@ -70,21 +70,21 @@ export interface UserWithRoles extends BaseUser {
70
70
  * @throws AuthenticationError
71
71
  *
72
72
  * @example
73
- * ```typescript
73
+ * typescript
74
74
  * import { requireUser } from '@mandujs/core'
75
75
  *
76
76
  * export default Mandu.filling()
77
- * .guard(async (ctx) => {
77
+ * .beforeHandle(async (ctx) => {
78
78
  * // JWT 토큰 검증 후 user 저장
79
79
  * const user = await verifyToken(ctx.headers.get('Authorization'));
80
80
  * ctx.set('user', user);
81
- * return ctx.next();
81
+ * // void 반환 시 계속 진행
82
82
  * })
83
83
  * .get((ctx) => {
84
84
  * const user = requireUser(ctx); // User 타입 확정, 없으면 401
85
- * return ctx.ok({ message: `Hello, ${user.id}!` });
85
+ * return ctx.ok({ message: "Hello, " + user.id + "!" });
86
86
  * })
87
- * ```
87
+ *
88
88
  */
89
89
  export function requireUser<T extends BaseUser = BaseUser>(
90
90
  ctx: ManduContext,
@@ -114,12 +114,12 @@ export function requireUser<T extends BaseUser = BaseUser>(
114
114
  * @throws AuthorizationError (역할 불일치)
115
115
  *
116
116
  * @example
117
- * ```typescript
118
- * .guard((ctx) => {
117
+ * typescript
118
+ * .beforeHandle((ctx) => {
119
119
  * requireRole(ctx, 'admin', 'moderator'); // admin 또는 moderator만 허용
120
- * return ctx.next();
120
+ * // void 반환 시 계속 진행
121
121
  * })
122
- * ```
122
+ *
123
123
  */
124
124
  export function requireRole<T extends UserWithRole = UserWithRole>(
125
125
  ctx: ManduContext,
@@ -133,7 +133,7 @@ export function requireRole<T extends UserWithRole = UserWithRole>(
133
133
 
134
134
  if (!roles.includes(user.role)) {
135
135
  throw new AuthorizationError(
136
- `Required role: ${roles.join(" or ")}`,
136
+ "Required role: " + roles.join(" or "),
137
137
  roles
138
138
  );
139
139
  }
@@ -152,12 +152,12 @@ export function requireRole<T extends UserWithRole = UserWithRole>(
152
152
  * @throws AuthorizationError (역할 불일치)
153
153
  *
154
154
  * @example
155
- * ```typescript
156
- * .guard((ctx) => {
155
+ * typescript
156
+ * .beforeHandle((ctx) => {
157
157
  * requireAnyRole(ctx, 'editor', 'admin'); // editor 또는 admin 역할 필요
158
- * return ctx.next();
158
+ * // void 반환 시 계속 진행
159
159
  * })
160
- * ```
160
+ *
161
161
  */
162
162
  export function requireAnyRole<T extends UserWithRoles = UserWithRoles>(
163
163
  ctx: ManduContext,
@@ -173,7 +173,7 @@ export function requireAnyRole<T extends UserWithRoles = UserWithRoles>(
173
173
 
174
174
  if (!hasRole) {
175
175
  throw new AuthorizationError(
176
- `Required one of roles: ${roles.join(", ")}`,
176
+ "Required one of roles: " + roles.join(", "),
177
177
  roles
178
178
  );
179
179
  }
@@ -191,12 +191,12 @@ export function requireAnyRole<T extends UserWithRoles = UserWithRoles>(
191
191
  * @throws AuthorizationError (역할 불일치)
192
192
  *
193
193
  * @example
194
- * ```typescript
195
- * .guard((ctx) => {
194
+ * typescript
195
+ * .beforeHandle((ctx) => {
196
196
  * requireAllRoles(ctx, 'verified', 'premium'); // verified AND premium 필요
197
- * return ctx.next();
197
+ * // void 반환 시 계속 진행
198
198
  * })
199
- * ```
199
+ *
200
200
  */
201
201
  export function requireAllRoles<T extends UserWithRoles = UserWithRoles>(
202
202
  ctx: ManduContext,
@@ -212,7 +212,7 @@ export function requireAllRoles<T extends UserWithRoles = UserWithRoles>(
212
212
 
213
213
  if (missingRoles.length > 0) {
214
214
  throw new AuthorizationError(
215
- `Missing required roles: ${missingRoles.join(", ")}`,
215
+ "Missing required roles: " + missingRoles.join(", "),
216
216
  roles
217
217
  );
218
218
  }
@@ -221,28 +221,28 @@ export function requireAllRoles<T extends UserWithRoles = UserWithRoles>(
221
221
  }
222
222
 
223
223
  // ============================================
224
- // 🔐 Auth Guard Factory
224
+ // 🔐 Auth Handler Factory
225
225
  // ============================================
226
226
 
227
227
  /**
228
- * 인증 Guard 생성 팩토리
229
- * 반복되는 인증 로직을 Guard로 변환
228
+ * 인증 beforeHandle 생성 팩토리
229
+ * 반복되는 인증 로직을 beforeHandle로 변환
230
230
  *
231
231
  * @example
232
- * ```typescript
233
- * const authGuard = createAuthGuard(async (ctx) => {
232
+ * typescript
233
+ * const authHandler = createAuthGuard(async (ctx) => {
234
234
  * const token = ctx.headers.get('Authorization')?.replace('Bearer ', '');
235
235
  * if (!token) return null;
236
236
  * return await verifyJwt(token);
237
237
  * });
238
238
  *
239
239
  * export default Mandu.filling()
240
- * .guard(authGuard)
240
+ * .beforeHandle(authHandler)
241
241
  * .get((ctx) => {
242
242
  * const user = requireUser(ctx);
243
243
  * return ctx.ok({ user });
244
244
  * })
245
- * ```
245
+ *
246
246
  */
247
247
  export function createAuthGuard<T extends BaseUser>(
248
248
  authenticator: (ctx: ManduContext) => T | null | Promise<T | null>,
@@ -253,13 +253,13 @@ export function createAuthGuard<T extends BaseUser>(
253
253
  ) {
254
254
  const { key = "user", onUnauthenticated } = options;
255
255
 
256
- return async (ctx: ManduContext): Promise<symbol | Response> => {
256
+ return async (ctx: ManduContext): Promise<Response | void> => {
257
257
  try {
258
258
  const user = await authenticator(ctx);
259
259
 
260
260
  if (user) {
261
261
  ctx.set(key, user);
262
- return ctx.next();
262
+ return; // void 반환 시 계속 진행
263
263
  }
264
264
 
265
265
  if (onUnauthenticated) {
@@ -277,24 +277,24 @@ export function createAuthGuard<T extends BaseUser>(
277
277
  }
278
278
 
279
279
  /**
280
- * 역할 기반 Guard 생성 팩토리
280
+ * 역할 기반 beforeHandle 생성 팩토리
281
281
  *
282
282
  * @example
283
- * ```typescript
283
+ * typescript
284
284
  * const adminOnly = createRoleGuard('admin');
285
285
  * const editorOrAdmin = createRoleGuard('editor', 'admin');
286
286
  *
287
287
  * export default Mandu.filling()
288
- * .guard(authGuard)
289
- * .guard(adminOnly) // admin만 접근 가능
288
+ * .beforeHandle(authHandler)
289
+ * .beforeHandle(adminOnly) // admin만 접근 가능
290
290
  * .delete((ctx) => ctx.noContent())
291
- * ```
291
+ *
292
292
  */
293
293
  export function createRoleGuard(...allowedRoles: string[]) {
294
- return (ctx: ManduContext): symbol | Response => {
294
+ return (ctx: ManduContext): Response | void => {
295
295
  try {
296
296
  requireRole(ctx, ...allowedRoles);
297
- return ctx.next();
297
+ return; // void 반환 시 계속 진행
298
298
  } catch (error) {
299
299
  if (error instanceof AuthenticationError) {
300
300
  return ctx.unauthorized(error.message);
@@ -10,17 +10,23 @@ import { createContract, type ContractDefinition, type ContractInstance } from "
10
10
  import {
11
11
  type LifecycleStore,
12
12
  type OnRequestHandler,
13
+ type OnParseHandler,
13
14
  type BeforeHandleHandler,
14
15
  type AfterHandleHandler,
16
+ type MapResponseHandler,
15
17
  type OnErrorHandler,
16
18
  type AfterResponseHandler,
17
19
  createLifecycleStore,
18
20
  executeLifecycle,
21
+ type ExecuteOptions,
19
22
  } from "../runtime/lifecycle";
20
23
 
21
24
  /** Handler function type */
22
25
  export type Handler = (ctx: ManduContext) => Response | Promise<Response>;
23
26
 
27
+ /** Guard function type (alias of BeforeHandle) */
28
+ export type Guard = BeforeHandleHandler;
29
+
24
30
  /** HTTP methods */
25
31
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
26
32
 
@@ -133,16 +139,41 @@ export class ManduFilling<TLoaderData = unknown> {
133
139
  return this;
134
140
  }
135
141
 
142
+ onParse(fn: OnParseHandler): this {
143
+ this.config.lifecycle.onParse.push({ fn, scope: "local" });
144
+ return this;
145
+ }
146
+
136
147
  beforeHandle(fn: BeforeHandleHandler): this {
137
148
  this.config.lifecycle.beforeHandle.push({ fn, scope: "local" });
138
149
  return this;
139
150
  }
140
151
 
152
+ /**
153
+ * Guard alias (beforeHandle와 동일)
154
+ * 인증/인가, 요청 차단 등에 사용
155
+ */
156
+ guard(fn: Guard): this {
157
+ return this.beforeHandle(fn);
158
+ }
159
+
160
+ /**
161
+ * Middleware alias (guard와 동일)
162
+ */
163
+ use(fn: Guard): this {
164
+ return this.guard(fn);
165
+ }
166
+
141
167
  afterHandle(fn: AfterHandleHandler): this {
142
168
  this.config.lifecycle.afterHandle.push({ fn, scope: "local" });
143
169
  return this;
144
170
  }
145
171
 
172
+ mapResponse(fn: MapResponseHandler): this {
173
+ this.config.lifecycle.mapResponse.push({ fn, scope: "local" });
174
+ return this;
175
+ }
176
+
146
177
  onError(fn: OnErrorHandler): this {
147
178
  this.config.lifecycle.onError.push({ fn, scope: "local" });
148
179
  return this;
@@ -156,7 +187,8 @@ export class ManduFilling<TLoaderData = unknown> {
156
187
  async handle(
157
188
  request: Request,
158
189
  params: Record<string, string> = {},
159
- routeContext?: { routeId: string; pattern: string }
190
+ routeContext?: { routeId: string; pattern: string },
191
+ options?: ExecuteOptions
160
192
  ): Promise<Response> {
161
193
  const ctx = new ManduContext(request, params);
162
194
  const method = request.method.toUpperCase() as HttpMethod;
@@ -165,7 +197,7 @@ export class ManduFilling<TLoaderData = unknown> {
165
197
  return ctx.json({ status: "error", message: `Method ${method} not allowed`, allowed: Array.from(this.config.handlers.keys()) }, 405);
166
198
  }
167
199
  const lifecycleWithDefaults = this.createLifecycleWithDefaults(routeContext);
168
- return executeLifecycle(lifecycleWithDefaults, ctx, async () => handler(ctx));
200
+ return executeLifecycle(lifecycleWithDefaults, ctx, async () => handler(ctx), options);
169
201
  }
170
202
 
171
203
  private createLifecycleWithDefaults(routeContext?: { routeId: string; pattern: string }): LifecycleStore {
@@ -1,21 +1,21 @@
1
- /**
2
- * Mandu Filling Module - 만두소 🥟
3
- */
4
-
5
- export { ManduContext, NEXT_SYMBOL, ValidationError, CookieManager } from "./context";
6
- export type { CookieOptions } from "./context";
7
- export { ManduFilling, Mandu, LoaderTimeoutError } from "./filling";
8
- export type { Handler, Guard, HttpMethod, Loader, LoaderOptions } from "./filling";
9
-
10
- // Auth Guards
11
- export {
12
- AuthenticationError,
13
- AuthorizationError,
14
- requireUser,
15
- requireRole,
16
- requireAnyRole,
17
- requireAllRoles,
18
- createAuthGuard,
19
- createRoleGuard,
20
- } from "./auth";
21
- export type { BaseUser, UserWithRole, UserWithRoles } from "./auth";
1
+ /**
2
+ * Mandu Filling Module - 만두소 🥟
3
+ */
4
+
5
+ export { ManduContext, ValidationError, CookieManager } from "./context";
6
+ export type { CookieOptions } from "./context";
7
+ export { ManduFilling, Mandu, LoaderTimeoutError } from "./filling";
8
+ export type { Handler, Guard, HttpMethod, Loader, LoaderOptions } from "./filling";
9
+
10
+ // Auth Guards
11
+ export {
12
+ AuthenticationError,
13
+ AuthorizationError,
14
+ requireUser,
15
+ requireRole,
16
+ requireAnyRole,
17
+ requireAllRoles,
18
+ createAuthGuard,
19
+ createRoleGuard,
20
+ } from "./auth";
21
+ export type { BaseUser, UserWithRole, UserWithRoles } from "./auth";
@@ -0,0 +1 @@
1
+ /c/Users/LamySolution/workspace/mandu/packages/core/src/filling
@@ -0,0 +1 @@
1
+ /c/Users/LamySolution/workspace/mandu/packages/core/src/filling
@@ -0,0 +1 @@
1
+ /c/Users/LamySolution/workspace/mandu/packages/core/src/filling
@@ -0,0 +1 @@
1
+ /c/Users/LamySolution/workspace/mandu/packages/core/src/filling
@@ -0,0 +1 @@
1
+ /c/Users/LamySolution/workspace/mandu/packages/core/src/filling
@@ -3,5 +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";
6
+ export * from "./compose";
7
+ export * from "./lifecycle";
8
+ export * from "./trace";
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import type { ManduContext } from "../filling/context";
21
+ import { createTracer } from "./trace";
21
22
 
22
23
  /**
23
24
  * 훅 스코프
@@ -115,6 +116,8 @@ export function createLifecycleStore(): LifecycleStore {
115
116
  export interface ExecuteOptions {
116
117
  /** 바디 파싱이 필요한 메서드 */
117
118
  parseBodyMethods?: string[];
119
+ /** 트레이스 활성화 */
120
+ trace?: boolean;
118
121
  }
119
122
 
120
123
  const DEFAULT_PARSE_BODY_METHODS = ["POST", "PUT", "PATCH"];
@@ -147,59 +150,74 @@ export async function executeLifecycle(
147
150
  options: ExecuteOptions = {}
148
151
  ): Promise<Response> {
149
152
  const { parseBodyMethods = DEFAULT_PARSE_BODY_METHODS } = options;
153
+ const tracer = createTracer(ctx, options.trace);
150
154
  let response: Response;
151
155
 
152
156
  try {
153
157
  // 1. onRequest
158
+ const endRequest = tracer.begin("request");
154
159
  for (const hook of lifecycle.onRequest) {
155
160
  await hook.fn(ctx);
156
161
  }
162
+ endRequest();
157
163
 
158
164
  // 2. onParse (바디가 있는 메서드만)
159
165
  if (parseBodyMethods.includes(ctx.req.method)) {
166
+ const endParse = tracer.begin("parse");
160
167
  for (const hook of lifecycle.onParse) {
161
168
  await hook.fn(ctx);
162
169
  }
170
+ endParse();
163
171
  }
164
172
 
165
173
  // 3. beforeHandle (Guard 역할)
174
+ const endBefore = tracer.begin("beforeHandle");
166
175
  for (const hook of lifecycle.beforeHandle) {
167
176
  const result = await hook.fn(ctx);
168
177
  if (result instanceof Response) {
169
178
  // Response 반환 시 체인 중단, afterHandle/mapResponse 건너뜀
170
179
  response = result;
180
+ endBefore();
171
181
  // afterResponse는 실행
172
- scheduleAfterResponse(lifecycle.afterResponse, ctx);
182
+ scheduleAfterResponse(lifecycle.afterResponse, ctx, tracer);
173
183
  return response;
174
184
  }
175
185
  }
186
+ endBefore();
176
187
 
177
188
  // 4. 메인 핸들러 실행
189
+ const endHandle = tracer.begin("handle");
178
190
  response = await handler();
191
+ endHandle();
179
192
 
180
193
  // 5. afterHandle
194
+ const endAfter = tracer.begin("afterHandle");
181
195
  for (const hook of lifecycle.afterHandle) {
182
196
  response = await hook.fn(ctx, response);
183
197
  }
198
+ endAfter();
184
199
 
185
200
  // 6. mapResponse
201
+ const endMap = tracer.begin("mapResponse");
186
202
  for (const hook of lifecycle.mapResponse) {
187
203
  response = await hook.fn(ctx, response);
188
204
  }
205
+ endMap();
189
206
 
190
207
  // 7. afterResponse (비동기)
191
- scheduleAfterResponse(lifecycle.afterResponse, ctx);
208
+ scheduleAfterResponse(lifecycle.afterResponse, ctx, tracer);
192
209
 
193
210
  return response;
194
211
  } catch (err) {
195
212
  // onError 처리
196
213
  const error = err instanceof Error ? err : new Error(String(err));
214
+ tracer.error("error", error);
197
215
 
198
216
  for (const hook of lifecycle.onError) {
199
217
  const result = await hook.fn(ctx, error);
200
218
  if (result instanceof Response) {
201
219
  // afterResponse는 에러 시에도 실행
202
- scheduleAfterResponse(lifecycle.afterResponse, ctx);
220
+ scheduleAfterResponse(lifecycle.afterResponse, ctx, tracer);
203
221
  return result;
204
222
  }
205
223
  }
@@ -214,12 +232,14 @@ export async function executeLifecycle(
214
232
  */
215
233
  function scheduleAfterResponse(
216
234
  hooks: HookContainer<AfterResponseHandler>[],
217
- ctx: ManduContext
235
+ ctx: ManduContext,
236
+ tracer?: ReturnType<typeof createTracer>
218
237
  ): void {
219
238
  if (hooks.length === 0) return;
220
239
 
221
240
  // queueMicrotask로 응답 후 실행
222
241
  queueMicrotask(async () => {
242
+ const endAfterResponse = tracer?.begin("afterResponse") ?? (() => {});
223
243
  for (const hook of hooks) {
224
244
  try {
225
245
  await hook.fn(ctx);
@@ -227,6 +247,7 @@ function scheduleAfterResponse(
227
247
  console.error("[Mandu] afterResponse hook error:", err);
228
248
  }
229
249
  }
250
+ endAfterResponse();
230
251
  });
231
252
  }
232
253
 
@@ -1,313 +1,315 @@
1
- import { renderToString } from "react-dom/server";
2
- import type { ReactElement } from "react";
3
- import type { BundleManifest } from "../bundler/types";
4
- import type { HydrationConfig, HydrationPriority } from "../spec/schema";
5
-
6
- export interface SSROptions {
7
- title?: string;
8
- lang?: string;
9
- /** 서버에서 로드한 데이터 (클라이언트로 전달) */
10
- serverData?: Record<string, unknown>;
11
- /** Hydration 설정 */
12
- hydration?: HydrationConfig;
13
- /** 번들 매니페스트 */
14
- bundleManifest?: BundleManifest;
15
- /** 라우트 ID (island 식별용) */
16
- routeId?: string;
17
- /** 추가 head 태그 */
18
- headTags?: string;
19
- /** 추가 body 끝 태그 */
20
- bodyEndTags?: string;
21
- /** 개발 모드 여부 */
22
- isDev?: boolean;
23
- /** HMR 포트 (개발 모드에서 사용) */
24
- hmrPort?: number;
25
- /** Client-side Routing 활성화 여부 */
26
- enableClientRouter?: boolean;
27
- /** 라우트 패턴 (Client-side Routing용) */
28
- routePattern?: string;
29
- }
30
-
31
- /**
32
- * SSR 데이터를 안전하게 직렬화
33
- */
34
- function serializeServerData(data: Record<string, unknown>): string {
35
- // XSS 방지를 위한 이스케이프
36
- const json = JSON.stringify(data)
37
- .replace(/</g, "\\u003c")
38
- .replace(/>/g, "\\u003e")
39
- .replace(/&/g, "\\u0026")
40
- .replace(/'/g, "\\u0027");
41
-
42
- return `<script id="__MANDU_DATA__" type="application/json">${json}</script>
43
- <script>window.__MANDU_DATA__ = JSON.parse(document.getElementById('__MANDU_DATA__').textContent);</script>`;
44
- }
45
-
46
- /**
47
- * Import map 생성 (bare specifier 해결용)
48
- */
49
- function generateImportMap(manifest: BundleManifest): string {
50
- if (!manifest.importMap || Object.keys(manifest.importMap.imports).length === 0) {
51
- return "";
52
- }
53
-
54
- const importMapJson = JSON.stringify(manifest.importMap, null, 2);
55
- return `<script type="importmap">${importMapJson}</script>`;
56
- }
57
-
58
- /**
59
- * Hydration 스크립트 태그 생성
60
- */
61
- function generateHydrationScripts(
62
- routeId: string,
63
- manifest: BundleManifest
64
- ): string {
65
- const scripts: string[] = [];
66
-
67
- // Import map 먼저 (반드시 module scripts 전에 위치해야 함)
68
- const importMap = generateImportMap(manifest);
69
- if (importMap) {
70
- scripts.push(importMap);
71
- }
72
-
73
- // Runtime 로드
74
- if (manifest.shared.runtime) {
75
- scripts.push(`<script type="module" src="${manifest.shared.runtime}"></script>`);
76
- }
77
-
78
- // Island 번들 로드
79
- const bundle = manifest.bundles[routeId];
80
- if (bundle) {
81
- // Preload (선택적)
82
- scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
83
- scripts.push(`<script type="module" src="${bundle.js}"></script>`);
84
- }
85
-
86
- return scripts.join("\n");
87
- }
88
-
89
- /**
90
- * Island 래퍼로 컨텐츠 감싸기
91
- */
92
- export function wrapWithIsland(
93
- content: string,
94
- routeId: string,
95
- priority: HydrationPriority = "visible"
96
- ): string {
97
- return `<div data-mandu-island="${routeId}" data-mandu-priority="${priority}">${content}</div>`;
98
- }
99
-
100
- export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
101
- const {
102
- title = "Mandu App",
103
- lang = "ko",
104
- serverData,
105
- hydration,
106
- bundleManifest,
107
- routeId,
108
- headTags = "",
109
- bodyEndTags = "",
110
- isDev = false,
111
- hmrPort,
112
- enableClientRouter = false,
113
- routePattern,
114
- } = options;
115
-
116
- let content = renderToString(element);
117
-
118
- // Island 래퍼 적용 (hydration 필요 시)
119
- const needsHydration =
120
- hydration && hydration.strategy !== "none" && routeId && bundleManifest;
121
-
122
- if (needsHydration) {
123
- content = wrapWithIsland(content, routeId, hydration.priority);
124
- }
125
-
126
- // 서버 데이터 스크립트
127
- let dataScript = "";
128
- if (serverData && routeId) {
129
- const wrappedData = {
130
- [routeId]: {
131
- serverData,
132
- timestamp: Date.now(),
133
- },
134
- };
135
- dataScript = serializeServerData(wrappedData);
136
- }
137
-
138
- // Client-side Routing: 라우트 정보 주입
139
- let routeScript = "";
140
- if (enableClientRouter && routeId) {
141
- routeScript = generateRouteScript(routeId, routePattern || "", serverData);
142
- }
143
-
144
- // Hydration 스크립트
145
- let hydrationScripts = "";
146
- if (needsHydration && bundleManifest) {
147
- hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
148
- }
149
-
150
- // Client-side Router 스크립트
151
- let routerScript = "";
152
- if (enableClientRouter && bundleManifest) {
153
- routerScript = generateClientRouterScript(bundleManifest);
154
- }
155
-
156
- // HMR 스크립트 (개발 모드)
157
- let hmrScript = "";
158
- if (isDev && hmrPort) {
159
- hmrScript = generateHMRScript(hmrPort);
160
- }
161
-
162
- return `<!doctype html>
163
- <html lang="${lang}">
164
- <head>
165
- <meta charset="UTF-8">
166
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
167
- <title>${title}</title>
168
- ${headTags}
169
- </head>
170
- <body>
171
- <div id="root">${content}</div>
172
- ${dataScript}
173
- ${routeScript}
174
- ${hydrationScripts}
175
- ${routerScript}
176
- ${hmrScript}
177
- ${bodyEndTags}
178
- </body>
179
- </html>`;
180
- }
181
-
182
- /**
183
- * Client-side Routing: 현재 라우트 정보 스크립트 생성
184
- */
185
- function generateRouteScript(
186
- routeId: string,
187
- pattern: string,
188
- serverData?: Record<string, unknown>
189
- ): string {
190
- const routeInfo = {
191
- id: routeId,
192
- pattern,
193
- params: extractParamsFromUrl(pattern),
194
- };
195
-
196
- const json = JSON.stringify(routeInfo)
197
- .replace(/</g, "\\u003c")
198
- .replace(/>/g, "\\u003e");
199
-
200
- return `<script>window.__MANDU_ROUTE__ = ${json};</script>`;
201
- }
202
-
203
- /**
204
- * URL 패턴에서 파라미터 추출 (클라이언트에서 사용)
205
- */
206
- function extractParamsFromUrl(pattern: string): Record<string, string> {
207
- // 서버에서는 실제 params를 전달받으므로 빈 객체 반환
208
- // 실제 params는 serverData나 별도 전달
209
- return {};
210
- }
211
-
212
- /**
213
- * Client-side Router 스크립트 로드
214
- */
215
- function generateClientRouterScript(manifest: BundleManifest): string {
216
- // Import map 먼저 (이미 hydration에서 추가되었을 수 있음)
217
- const scripts: string[] = [];
218
-
219
- // 라우터 번들이 있으면 로드
220
- if (manifest.shared?.router) {
221
- scripts.push(`<script type="module" src="${manifest.shared.router}"></script>`);
222
- }
223
-
224
- return scripts.join("\n");
225
- }
226
-
227
- /**
228
- * HMR 스크립트 생성
229
- */
230
- function generateHMRScript(port: number): string {
231
- const hmrPort = port + 1;
232
- return `<script>
233
- (function() {
234
- var ws = null;
235
- var reconnectAttempts = 0;
236
- var maxReconnectAttempts = 10;
237
-
238
- function connect() {
239
- try {
240
- ws = new WebSocket('ws://localhost:${hmrPort}');
241
- ws.onopen = function() {
242
- console.log('[Mandu HMR] Connected');
243
- reconnectAttempts = 0;
244
- };
245
- ws.onmessage = function(e) {
246
- try {
247
- var msg = JSON.parse(e.data);
248
- if (msg.type === 'reload' || msg.type === 'island-update') {
249
- console.log('[Mandu HMR] Reloading...');
250
- location.reload();
251
- } else if (msg.type === 'error') {
252
- console.error('[Mandu HMR] Build error:', msg.data?.message);
253
- }
254
- } catch(err) {}
255
- };
256
- ws.onclose = function() {
257
- if (reconnectAttempts < maxReconnectAttempts) {
258
- reconnectAttempts++;
259
- setTimeout(connect, 1000 * reconnectAttempts);
260
- }
261
- };
262
- } catch(err) {
263
- setTimeout(connect, 1000);
264
- }
265
- }
266
- connect();
267
- })();
268
- </script>`;
269
- }
270
-
271
- export function createHTMLResponse(html: string, status: number = 200): Response {
272
- return new Response(html, {
273
- status,
274
- headers: {
275
- "Content-Type": "text/html; charset=utf-8",
276
- },
277
- });
278
- }
279
-
280
- export function renderSSR(element: ReactElement, options: SSROptions = {}): Response {
281
- const html = renderToHTML(element, options);
282
- return createHTMLResponse(html);
283
- }
284
-
285
- /**
286
- * Hydration이 포함된 SSR 렌더링
287
- *
288
- * @example
289
- * ```typescript
290
- * const response = await renderWithHydration(
291
- * <TodoList todos={todos} />,
292
- * {
293
- * title: "할일 목록",
294
- * routeId: "todos",
295
- * serverData: { todos },
296
- * hydration: { strategy: "island", priority: "visible" },
297
- * bundleManifest,
298
- * }
299
- * );
300
- * ```
301
- */
302
- export async function renderWithHydration(
303
- element: ReactElement,
304
- options: SSROptions & {
305
- routeId: string;
306
- serverData: Record<string, unknown>;
307
- hydration: HydrationConfig;
308
- bundleManifest: BundleManifest;
309
- }
310
- ): Promise<Response> {
311
- const html = renderToHTML(element, options);
312
- return createHTMLResponse(html);
313
- }
1
+ import { renderToString } from "react-dom/server";
2
+ import { serializeProps } from "../client/serialize";
3
+ import type { ReactElement } from "react";
4
+ import type { BundleManifest } from "../bundler/types";
5
+ import type { HydrationConfig, HydrationPriority } from "../spec/schema";
6
+
7
+ export interface SSROptions {
8
+ title?: string;
9
+ lang?: string;
10
+ /** 서버에서 로드한 데이터 (클라이언트로 전달) */
11
+ serverData?: Record<string, unknown>;
12
+ /** Hydration 설정 */
13
+ hydration?: HydrationConfig;
14
+ /** 번들 매니페스트 */
15
+ bundleManifest?: BundleManifest;
16
+ /** 라우트 ID (island 식별용) */
17
+ routeId?: string;
18
+ /** 추가 head 태그 */
19
+ headTags?: string;
20
+ /** 추가 body 끝 태그 */
21
+ bodyEndTags?: string;
22
+ /** 개발 모드 여부 */
23
+ isDev?: boolean;
24
+ /** HMR 포트 (개발 모드에서 사용) */
25
+ hmrPort?: number;
26
+ /** Client-side Routing 활성화 여부 */
27
+ enableClientRouter?: boolean;
28
+ /** 라우트 패턴 (Client-side Routing용) */
29
+ routePattern?: string;
30
+ }
31
+
32
+ /**
33
+ * SSR 데이터를 안전하게 직렬화 (Fresh 스타일 고급 직렬화)
34
+ * Date, Map, Set, URL, RegExp, BigInt, 순환참조 지원
35
+ */
36
+ function serializeServerData(data: Record<string, unknown>): string {
37
+ // serializeProps로 고급 직렬화 (Date, Map, Set 등 지원)
38
+ const json = serializeProps(data)
39
+ .replace(/</g, "\\u003c")
40
+ .replace(/>/g, "\\u003e")
41
+ .replace(/&/g, "\\u0026")
42
+ .replace(/'/g, "\\u0027");
43
+
44
+ return `<script id="__MANDU_DATA__" type="application/json">${json}</script>
45
+ <script>window.__MANDU_DATA_RAW__ = document.getElementById('__MANDU_DATA__').textContent;</script>`;
46
+ }
47
+
48
+ /**
49
+ * Import map 생성 (bare specifier 해결용)
50
+ */
51
+ function generateImportMap(manifest: BundleManifest): string {
52
+ if (!manifest.importMap || Object.keys(manifest.importMap.imports).length === 0) {
53
+ return "";
54
+ }
55
+
56
+ const importMapJson = JSON.stringify(manifest.importMap, null, 2);
57
+ return `<script type="importmap">${importMapJson}</script>`;
58
+ }
59
+
60
+ /**
61
+ * Hydration 스크립트 태그 생성
62
+ */
63
+ function generateHydrationScripts(
64
+ routeId: string,
65
+ manifest: BundleManifest
66
+ ): string {
67
+ const scripts: string[] = [];
68
+
69
+ // Import map 먼저 (반드시 module scripts 전에 위치해야 함)
70
+ const importMap = generateImportMap(manifest);
71
+ if (importMap) {
72
+ scripts.push(importMap);
73
+ }
74
+
75
+ // Runtime 로드
76
+ if (manifest.shared.runtime) {
77
+ scripts.push(`<script type="module" src="${manifest.shared.runtime}"></script>`);
78
+ }
79
+
80
+ // Island 번들 로드
81
+ const bundle = manifest.bundles[routeId];
82
+ if (bundle) {
83
+ // Preload (선택적)
84
+ scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
85
+ scripts.push(`<script type="module" src="${bundle.js}"></script>`);
86
+ }
87
+
88
+ return scripts.join("\n");
89
+ }
90
+
91
+ /**
92
+ * Island 래퍼로 컨텐츠 감싸기
93
+ */
94
+ export function wrapWithIsland(
95
+ content: string,
96
+ routeId: string,
97
+ priority: HydrationPriority = "visible"
98
+ ): string {
99
+ return `<div data-mandu-island="${routeId}" data-mandu-priority="${priority}">${content}</div>`;
100
+ }
101
+
102
+ export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
103
+ const {
104
+ title = "Mandu App",
105
+ lang = "ko",
106
+ serverData,
107
+ hydration,
108
+ bundleManifest,
109
+ routeId,
110
+ headTags = "",
111
+ bodyEndTags = "",
112
+ isDev = false,
113
+ hmrPort,
114
+ enableClientRouter = false,
115
+ routePattern,
116
+ } = options;
117
+
118
+ let content = renderToString(element);
119
+
120
+ // Island 래퍼 적용 (hydration 필요 시)
121
+ const needsHydration =
122
+ hydration && hydration.strategy !== "none" && routeId && bundleManifest;
123
+
124
+ if (needsHydration) {
125
+ content = wrapWithIsland(content, routeId, hydration.priority);
126
+ }
127
+
128
+ // 서버 데이터 스크립트
129
+ let dataScript = "";
130
+ if (serverData && routeId) {
131
+ const wrappedData = {
132
+ [routeId]: {
133
+ serverData,
134
+ timestamp: Date.now(),
135
+ },
136
+ };
137
+ dataScript = serializeServerData(wrappedData);
138
+ }
139
+
140
+ // Client-side Routing: 라우트 정보 주입
141
+ let routeScript = "";
142
+ if (enableClientRouter && routeId) {
143
+ routeScript = generateRouteScript(routeId, routePattern || "", serverData);
144
+ }
145
+
146
+ // Hydration 스크립트
147
+ let hydrationScripts = "";
148
+ if (needsHydration && bundleManifest) {
149
+ hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
150
+ }
151
+
152
+ // Client-side Router 스크립트
153
+ let routerScript = "";
154
+ if (enableClientRouter && bundleManifest) {
155
+ routerScript = generateClientRouterScript(bundleManifest);
156
+ }
157
+
158
+ // HMR 스크립트 (개발 모드)
159
+ let hmrScript = "";
160
+ if (isDev && hmrPort) {
161
+ hmrScript = generateHMRScript(hmrPort);
162
+ }
163
+
164
+ return `<!doctype html>
165
+ <html lang="${lang}">
166
+ <head>
167
+ <meta charset="UTF-8">
168
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
169
+ <title>${title}</title>
170
+ ${headTags}
171
+ </head>
172
+ <body>
173
+ <div id="root">${content}</div>
174
+ ${dataScript}
175
+ ${routeScript}
176
+ ${hydrationScripts}
177
+ ${routerScript}
178
+ ${hmrScript}
179
+ ${bodyEndTags}
180
+ </body>
181
+ </html>`;
182
+ }
183
+
184
+ /**
185
+ * Client-side Routing: 현재 라우트 정보 스크립트 생성
186
+ */
187
+ function generateRouteScript(
188
+ routeId: string,
189
+ pattern: string,
190
+ serverData?: Record<string, unknown>
191
+ ): string {
192
+ const routeInfo = {
193
+ id: routeId,
194
+ pattern,
195
+ params: extractParamsFromUrl(pattern),
196
+ };
197
+
198
+ const json = JSON.stringify(routeInfo)
199
+ .replace(/</g, "\\u003c")
200
+ .replace(/>/g, "\\u003e");
201
+
202
+ return `<script>window.__MANDU_ROUTE__ = ${json};</script>`;
203
+ }
204
+
205
+ /**
206
+ * URL 패턴에서 파라미터 추출 (클라이언트에서 사용)
207
+ */
208
+ function extractParamsFromUrl(pattern: string): Record<string, string> {
209
+ // 서버에서는 실제 params를 전달받으므로 빈 객체 반환
210
+ // 실제 params는 serverData나 별도 전달
211
+ return {};
212
+ }
213
+
214
+ /**
215
+ * Client-side Router 스크립트 로드
216
+ */
217
+ function generateClientRouterScript(manifest: BundleManifest): string {
218
+ // Import map 먼저 (이미 hydration에서 추가되었을 수 있음)
219
+ const scripts: string[] = [];
220
+
221
+ // 라우터 번들이 있으면 로드
222
+ if (manifest.shared?.router) {
223
+ scripts.push(`<script type="module" src="${manifest.shared.router}"></script>`);
224
+ }
225
+
226
+ return scripts.join("\n");
227
+ }
228
+
229
+ /**
230
+ * HMR 스크립트 생성
231
+ */
232
+ function generateHMRScript(port: number): string {
233
+ const hmrPort = port + 1;
234
+ return `<script>
235
+ (function() {
236
+ var ws = null;
237
+ var reconnectAttempts = 0;
238
+ var maxReconnectAttempts = 10;
239
+
240
+ function connect() {
241
+ try {
242
+ ws = new WebSocket('ws://localhost:${hmrPort}');
243
+ ws.onopen = function() {
244
+ console.log('[Mandu HMR] Connected');
245
+ reconnectAttempts = 0;
246
+ };
247
+ ws.onmessage = function(e) {
248
+ try {
249
+ var msg = JSON.parse(e.data);
250
+ if (msg.type === 'reload' || msg.type === 'island-update') {
251
+ console.log('[Mandu HMR] Reloading...');
252
+ location.reload();
253
+ } else if (msg.type === 'error') {
254
+ console.error('[Mandu HMR] Build error:', msg.data?.message);
255
+ }
256
+ } catch(err) {}
257
+ };
258
+ ws.onclose = function() {
259
+ if (reconnectAttempts < maxReconnectAttempts) {
260
+ reconnectAttempts++;
261
+ setTimeout(connect, 1000 * reconnectAttempts);
262
+ }
263
+ };
264
+ } catch(err) {
265
+ setTimeout(connect, 1000);
266
+ }
267
+ }
268
+ connect();
269
+ })();
270
+ </script>`;
271
+ }
272
+
273
+ export function createHTMLResponse(html: string, status: number = 200): Response {
274
+ return new Response(html, {
275
+ status,
276
+ headers: {
277
+ "Content-Type": "text/html; charset=utf-8",
278
+ },
279
+ });
280
+ }
281
+
282
+ export function renderSSR(element: ReactElement, options: SSROptions = {}): Response {
283
+ const html = renderToHTML(element, options);
284
+ return createHTMLResponse(html);
285
+ }
286
+
287
+ /**
288
+ * Hydration이 포함된 SSR 렌더링
289
+ *
290
+ * @example
291
+ * ```typescript
292
+ * const response = await renderWithHydration(
293
+ * <TodoList todos={todos} />,
294
+ * {
295
+ * title: "할일 목록",
296
+ * routeId: "todos",
297
+ * serverData: { todos },
298
+ * hydration: { strategy: "island", priority: "visible" },
299
+ * bundleManifest,
300
+ * }
301
+ * );
302
+ * ```
303
+ */
304
+ export async function renderWithHydration(
305
+ element: ReactElement,
306
+ options: SSROptions & {
307
+ routeId: string;
308
+ serverData: Record<string, unknown>;
309
+ hydration: HydrationConfig;
310
+ bundleManifest: BundleManifest;
311
+ }
312
+ ): Promise<Response> {
313
+ const html = renderToHTML(element, options);
314
+ return createHTMLResponse(html);
315
+ }
@@ -0,0 +1 @@
1
+ /c/Users/LamySolution/workspace/mandu/packages/core/src/runtime
@@ -0,0 +1 @@
1
+ /c/Users/LamySolution/workspace/mandu/packages/core/src/runtime
@@ -0,0 +1 @@
1
+ /c/Users/LamySolution/workspace/mandu/packages/core/src/runtime
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Mandu Trace 🧭
3
+ * Lifecycle 단계별 추적 (옵션)
4
+ */
5
+
6
+ import type { ManduContext } from "../filling/context";
7
+
8
+ export type TraceEvent =
9
+ | "request"
10
+ | "parse"
11
+ | "transform"
12
+ | "beforeHandle"
13
+ | "handle"
14
+ | "afterHandle"
15
+ | "mapResponse"
16
+ | "afterResponse"
17
+ | "error";
18
+
19
+ export type TracePhase = "begin" | "end" | "error";
20
+
21
+ export interface TraceEntry {
22
+ event: TraceEvent;
23
+ phase: TracePhase;
24
+ time: number;
25
+ name?: string;
26
+ error?: string;
27
+ }
28
+
29
+ export interface TraceCollector {
30
+ records: TraceEntry[];
31
+ }
32
+
33
+ export const TRACE_KEY = "__mandu_trace";
34
+
35
+ const now = (): number => {
36
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
37
+ return performance.now();
38
+ }
39
+ return Date.now();
40
+ };
41
+
42
+ export function enableTrace(ctx: ManduContext): TraceCollector {
43
+ const existing = ctx.get<TraceCollector>(TRACE_KEY);
44
+ if (existing) return existing;
45
+ const collector: TraceCollector = { records: [] };
46
+ ctx.set(TRACE_KEY, collector);
47
+ return collector;
48
+ }
49
+
50
+ export function getTrace(ctx: ManduContext): TraceCollector | undefined {
51
+ return ctx.get<TraceCollector>(TRACE_KEY);
52
+ }
53
+
54
+ export interface Tracer {
55
+ enabled: boolean;
56
+ begin: (event: TraceEvent, name?: string) => () => void;
57
+ error: (event: TraceEvent, err: unknown, name?: string) => void;
58
+ }
59
+
60
+ const NOOP_TRACER: Tracer = {
61
+ enabled: false,
62
+ begin: () => () => {},
63
+ error: () => {},
64
+ };
65
+
66
+ export function createTracer(ctx: ManduContext, enabled?: boolean): Tracer {
67
+ const shouldEnable = Boolean(enabled) || ctx.has(TRACE_KEY);
68
+ if (!shouldEnable) return NOOP_TRACER;
69
+
70
+ const collector = enableTrace(ctx);
71
+
72
+ return {
73
+ enabled: true,
74
+ begin: (event, name) => {
75
+ collector.records.push({ event, phase: "begin", time: now(), name });
76
+ return () => {
77
+ collector.records.push({ event, phase: "end", time: now(), name });
78
+ };
79
+ },
80
+ error: (event, err, name) => {
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ collector.records.push({ event, phase: "error", time: now(), name, error: message });
83
+ },
84
+ };
85
+ }