@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 +1 -1
- package/src/filling/auth.ts +40 -40
- package/src/filling/filling.ts +34 -2
- package/src/filling/index.ts +21 -21
- package/src/filling/tmpclaude-2fc1-cwd +1 -0
- package/src/filling/tmpclaude-59ee-cwd +1 -0
- package/src/filling/tmpclaude-7608-cwd +1 -0
- package/src/filling/tmpclaude-a102-cwd +1 -0
- package/src/filling/tmpclaude-bf2c-cwd +1 -0
- package/src/runtime/index.ts +3 -2
- package/src/runtime/lifecycle.ts +25 -4
- package/src/runtime/ssr.ts +315 -313
- package/src/runtime/tmpclaude-1f31-cwd +1 -0
- package/src/runtime/tmpclaude-8527-cwd +1 -0
- package/src/runtime/tmpclaude-e62c-cwd +1 -0
- package/src/runtime/trace.ts +85 -0
package/package.json
CHANGED
package/src/filling/auth.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mandu Auth Guards - 인증/인가 헬퍼 🔐
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 인증 실패 시 적절한 에러를 throw하여
|
|
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
|
|
60
|
+
// 🔐 Auth Helpers
|
|
61
61
|
// ============================================
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* 인증된 사용자 필수
|
|
65
|
-
*
|
|
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
|
-
*
|
|
73
|
+
* typescript
|
|
74
74
|
* import { requireUser } from '@mandujs/core'
|
|
75
75
|
*
|
|
76
76
|
* export default Mandu.filling()
|
|
77
|
-
* .
|
|
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
|
-
*
|
|
81
|
+
* // void 반환 시 계속 진행
|
|
82
82
|
* })
|
|
83
83
|
* .get((ctx) => {
|
|
84
84
|
* const user = requireUser(ctx); // User 타입 확정, 없으면 401
|
|
85
|
-
* return ctx.ok({ message:
|
|
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
|
-
*
|
|
118
|
-
* .
|
|
117
|
+
* typescript
|
|
118
|
+
* .beforeHandle((ctx) => {
|
|
119
119
|
* requireRole(ctx, 'admin', 'moderator'); // admin 또는 moderator만 허용
|
|
120
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
156
|
-
* .
|
|
155
|
+
* typescript
|
|
156
|
+
* .beforeHandle((ctx) => {
|
|
157
157
|
* requireAnyRole(ctx, 'editor', 'admin'); // editor 또는 admin 역할 필요
|
|
158
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
195
|
-
* .
|
|
194
|
+
* typescript
|
|
195
|
+
* .beforeHandle((ctx) => {
|
|
196
196
|
* requireAllRoles(ctx, 'verified', 'premium'); // verified AND premium 필요
|
|
197
|
-
*
|
|
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
|
-
|
|
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
|
|
224
|
+
// 🔐 Auth Handler Factory
|
|
225
225
|
// ============================================
|
|
226
226
|
|
|
227
227
|
/**
|
|
228
|
-
* 인증
|
|
229
|
-
* 반복되는 인증 로직을
|
|
228
|
+
* 인증 beforeHandle 생성 팩토리
|
|
229
|
+
* 반복되는 인증 로직을 beforeHandle로 변환
|
|
230
230
|
*
|
|
231
231
|
* @example
|
|
232
|
-
*
|
|
233
|
-
* const
|
|
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
|
-
* .
|
|
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<
|
|
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
|
|
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
|
-
* 역할 기반
|
|
280
|
+
* 역할 기반 beforeHandle 생성 팩토리
|
|
281
281
|
*
|
|
282
282
|
* @example
|
|
283
|
-
*
|
|
283
|
+
* typescript
|
|
284
284
|
* const adminOnly = createRoleGuard('admin');
|
|
285
285
|
* const editorOrAdmin = createRoleGuard('editor', 'admin');
|
|
286
286
|
*
|
|
287
287
|
* export default Mandu.filling()
|
|
288
|
-
* .
|
|
289
|
-
* .
|
|
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):
|
|
294
|
+
return (ctx: ManduContext): Response | void => {
|
|
295
295
|
try {
|
|
296
296
|
requireRole(ctx, ...allowedRoles);
|
|
297
|
-
return
|
|
297
|
+
return; // void 반환 시 계속 진행
|
|
298
298
|
} catch (error) {
|
|
299
299
|
if (error instanceof AuthenticationError) {
|
|
300
300
|
return ctx.unauthorized(error.message);
|
package/src/filling/filling.ts
CHANGED
|
@@ -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 {
|
package/src/filling/index.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu Filling Module - 만두소 🥟
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export { ManduContext,
|
|
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
|
package/src/runtime/index.ts
CHANGED
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -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
|
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -1,313 +1,315 @@
|
|
|
1
|
-
import { renderToString } from "react-dom/server";
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.replace(
|
|
40
|
-
.replace(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
</
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
${
|
|
175
|
-
${
|
|
176
|
-
${
|
|
177
|
-
${
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
var
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
}
|