@mandujs/core 0.5.4 → 0.5.6
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/README.ko.md +200 -200
- package/README.md +200 -200
- package/package.json +2 -2
- package/src/contract/validator.ts +2 -2
- package/src/filling/auth.ts +308 -0
- package/src/filling/context.ts +7 -1
- package/src/filling/filling.ts +83 -4
- package/src/filling/index.ts +15 -2
- package/src/generator/generate.ts +27 -6
- package/src/generator/index.ts +3 -3
- package/src/report/index.ts +1 -1
- package/src/runtime/index.ts +5 -5
- package/src/runtime/router.ts +83 -65
- package/src/runtime/server.ts +425 -425
- package/src/runtime/ssr.ts +248 -248
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
package/src/runtime/server.ts
CHANGED
|
@@ -1,425 +1,425 @@
|
|
|
1
|
-
import type { Server } from "bun";
|
|
2
|
-
import type { RoutesManifest } from "../spec/schema";
|
|
3
|
-
import type { BundleManifest } from "../bundler/types";
|
|
4
|
-
import { Router } from "./router";
|
|
5
|
-
import { renderSSR } from "./ssr";
|
|
6
|
-
import React from "react";
|
|
7
|
-
import path from "path";
|
|
8
|
-
import {
|
|
9
|
-
formatErrorResponse,
|
|
10
|
-
createNotFoundResponse,
|
|
11
|
-
createHandlerNotFoundResponse,
|
|
12
|
-
createPageLoadErrorResponse,
|
|
13
|
-
createSSRErrorResponse,
|
|
14
|
-
} from "../error";
|
|
15
|
-
import {
|
|
16
|
-
type CorsOptions,
|
|
17
|
-
isPreflightRequest,
|
|
18
|
-
handlePreflightRequest,
|
|
19
|
-
applyCorsToResponse,
|
|
20
|
-
isCorsRequest,
|
|
21
|
-
} from "./cors";
|
|
22
|
-
|
|
23
|
-
// ========== MIME Types ==========
|
|
24
|
-
const MIME_TYPES: Record<string, string> = {
|
|
25
|
-
// JavaScript
|
|
26
|
-
".js": "application/javascript",
|
|
27
|
-
".mjs": "application/javascript",
|
|
28
|
-
".ts": "application/typescript",
|
|
29
|
-
// CSS
|
|
30
|
-
".css": "text/css",
|
|
31
|
-
// HTML
|
|
32
|
-
".html": "text/html",
|
|
33
|
-
".htm": "text/html",
|
|
34
|
-
// JSON
|
|
35
|
-
".json": "application/json",
|
|
36
|
-
// Images
|
|
37
|
-
".png": "image/png",
|
|
38
|
-
".jpg": "image/jpeg",
|
|
39
|
-
".jpeg": "image/jpeg",
|
|
40
|
-
".gif": "image/gif",
|
|
41
|
-
".svg": "image/svg+xml",
|
|
42
|
-
".ico": "image/x-icon",
|
|
43
|
-
".webp": "image/webp",
|
|
44
|
-
".avif": "image/avif",
|
|
45
|
-
// Fonts
|
|
46
|
-
".woff": "font/woff",
|
|
47
|
-
".woff2": "font/woff2",
|
|
48
|
-
".ttf": "font/ttf",
|
|
49
|
-
".otf": "font/otf",
|
|
50
|
-
".eot": "application/vnd.ms-fontobject",
|
|
51
|
-
// Documents
|
|
52
|
-
".pdf": "application/pdf",
|
|
53
|
-
".txt": "text/plain",
|
|
54
|
-
".xml": "application/xml",
|
|
55
|
-
// Media
|
|
56
|
-
".mp3": "audio/mpeg",
|
|
57
|
-
".mp4": "video/mp4",
|
|
58
|
-
".webm": "video/webm",
|
|
59
|
-
".ogg": "audio/ogg",
|
|
60
|
-
// Archives
|
|
61
|
-
".zip": "application/zip",
|
|
62
|
-
".gz": "application/gzip",
|
|
63
|
-
// WebAssembly
|
|
64
|
-
".wasm": "application/wasm",
|
|
65
|
-
// Source maps
|
|
66
|
-
".map": "application/json",
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
function getMimeType(filePath: string): string {
|
|
70
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
71
|
-
return MIME_TYPES[ext] || "application/octet-stream";
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ========== Server Options ==========
|
|
75
|
-
export interface ServerOptions {
|
|
76
|
-
port?: number;
|
|
77
|
-
hostname?: string;
|
|
78
|
-
/** 프로젝트 루트 디렉토리 */
|
|
79
|
-
rootDir?: string;
|
|
80
|
-
/** 개발 모드 여부 */
|
|
81
|
-
isDev?: boolean;
|
|
82
|
-
/** HMR 포트 (개발 모드에서 사용) */
|
|
83
|
-
hmrPort?: number;
|
|
84
|
-
/** 번들 매니페스트 (Island hydration용) */
|
|
85
|
-
bundleManifest?: BundleManifest;
|
|
86
|
-
/** Public 디렉토리 경로 (기본: 'public') */
|
|
87
|
-
publicDir?: string;
|
|
88
|
-
/**
|
|
89
|
-
* CORS 설정
|
|
90
|
-
* - true: 모든 Origin 허용
|
|
91
|
-
* - false: CORS 비활성화 (기본값)
|
|
92
|
-
* - CorsOptions: 세부 설정
|
|
93
|
-
*/
|
|
94
|
-
cors?: boolean | CorsOptions;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface ManduServer {
|
|
98
|
-
server: Server;
|
|
99
|
-
router: Router;
|
|
100
|
-
stop: () => void;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
|
|
104
|
-
export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
|
|
105
|
-
|
|
106
|
-
export interface AppContext {
|
|
107
|
-
routeId: string;
|
|
108
|
-
url: string;
|
|
109
|
-
params: Record<string, string>;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
type RouteComponent = (props: { params: Record<string, string> }) => React.ReactElement;
|
|
113
|
-
type CreateAppFn = (context: AppContext) => React.ReactElement;
|
|
114
|
-
|
|
115
|
-
// Registry
|
|
116
|
-
const apiHandlers: Map<string, ApiHandler> = new Map();
|
|
117
|
-
const pageLoaders: Map<string, PageLoader> = new Map();
|
|
118
|
-
const routeComponents: Map<string, RouteComponent> = new Map();
|
|
119
|
-
let createAppFn: CreateAppFn | null = null;
|
|
120
|
-
|
|
121
|
-
// Server settings (module-level for handleRequest access)
|
|
122
|
-
let serverSettings: {
|
|
123
|
-
isDev: boolean;
|
|
124
|
-
hmrPort?: number;
|
|
125
|
-
bundleManifest?: BundleManifest;
|
|
126
|
-
rootDir: string;
|
|
127
|
-
publicDir: string;
|
|
128
|
-
cors?: CorsOptions | false;
|
|
129
|
-
} = {
|
|
130
|
-
isDev: false,
|
|
131
|
-
rootDir: process.cwd(),
|
|
132
|
-
publicDir: "public",
|
|
133
|
-
cors: false,
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
export function registerApiHandler(routeId: string, handler: ApiHandler): void {
|
|
137
|
-
apiHandlers.set(routeId, handler);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function registerPageLoader(routeId: string, loader: PageLoader): void {
|
|
141
|
-
pageLoaders.set(routeId, loader);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function registerRouteComponent(routeId: string, component: RouteComponent): void {
|
|
145
|
-
routeComponents.set(routeId, component);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function setCreateApp(fn: CreateAppFn): void {
|
|
149
|
-
createAppFn = fn;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Default createApp implementation
|
|
153
|
-
function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
154
|
-
const Component = routeComponents.get(context.routeId);
|
|
155
|
-
|
|
156
|
-
if (!Component) {
|
|
157
|
-
return React.createElement("div", null,
|
|
158
|
-
React.createElement("h1", null, "404 - Route Not Found"),
|
|
159
|
-
React.createElement("p", null, `Route ID: ${context.routeId}`)
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return React.createElement(Component, { params: context.params });
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ========== Static File Serving ==========
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* 정적 파일 서빙
|
|
170
|
-
* - /.mandu/client/* : 클라이언트 번들 (Island hydration)
|
|
171
|
-
* - /public/* : 정적 에셋 (이미지, CSS 등)
|
|
172
|
-
* - /favicon.ico : 파비콘
|
|
173
|
-
*/
|
|
174
|
-
async function serveStaticFile(pathname: string): Promise<Response | null> {
|
|
175
|
-
let filePath: string | null = null;
|
|
176
|
-
let isBundleFile = false;
|
|
177
|
-
|
|
178
|
-
// 1. 클라이언트 번들 파일 (/.mandu/client/*)
|
|
179
|
-
if (pathname.startsWith("/.mandu/client/")) {
|
|
180
|
-
filePath = path.join(serverSettings.rootDir, pathname);
|
|
181
|
-
isBundleFile = true;
|
|
182
|
-
}
|
|
183
|
-
// 2. Public 폴더 파일 (/public/* 또는 직접 접근)
|
|
184
|
-
else if (pathname.startsWith("/public/")) {
|
|
185
|
-
filePath = path.join(serverSettings.rootDir, pathname);
|
|
186
|
-
}
|
|
187
|
-
// 3. Public 폴더의 루트 파일 (favicon.ico, robots.txt 등)
|
|
188
|
-
else if (
|
|
189
|
-
pathname === "/favicon.ico" ||
|
|
190
|
-
pathname === "/robots.txt" ||
|
|
191
|
-
pathname === "/sitemap.xml" ||
|
|
192
|
-
pathname === "/manifest.json"
|
|
193
|
-
) {
|
|
194
|
-
filePath = path.join(serverSettings.rootDir, serverSettings.publicDir, pathname);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (!filePath) {
|
|
198
|
-
return null; // 정적 파일이 아님
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
const file = Bun.file(filePath);
|
|
203
|
-
const exists = await file.exists();
|
|
204
|
-
|
|
205
|
-
if (!exists) {
|
|
206
|
-
return null; // 파일 없음 - 라우트 매칭으로 넘김
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const mimeType = getMimeType(filePath);
|
|
210
|
-
|
|
211
|
-
// Cache-Control 헤더 설정
|
|
212
|
-
let cacheControl: string;
|
|
213
|
-
if (serverSettings.isDev) {
|
|
214
|
-
// 개발 모드: 캐시 없음
|
|
215
|
-
cacheControl = "no-cache, no-store, must-revalidate";
|
|
216
|
-
} else if (isBundleFile) {
|
|
217
|
-
// 프로덕션 번들: 1년 캐시 (파일명에 해시 포함 가정)
|
|
218
|
-
cacheControl = "public, max-age=31536000, immutable";
|
|
219
|
-
} else {
|
|
220
|
-
// 프로덕션 일반 정적 파일: 1일 캐시
|
|
221
|
-
cacheControl = "public, max-age=86400";
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return new Response(file, {
|
|
225
|
-
headers: {
|
|
226
|
-
"Content-Type": mimeType,
|
|
227
|
-
"Cache-Control": cacheControl,
|
|
228
|
-
},
|
|
229
|
-
});
|
|
230
|
-
} catch {
|
|
231
|
-
return null; // 파일 읽기 실패 - 라우트 매칭으로 넘김
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ========== Request Handler ==========
|
|
236
|
-
|
|
237
|
-
async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
238
|
-
const url = new URL(req.url);
|
|
239
|
-
const pathname = url.pathname;
|
|
240
|
-
|
|
241
|
-
// 0. CORS Preflight 요청 처리
|
|
242
|
-
if (serverSettings.cors && isPreflightRequest(req)) {
|
|
243
|
-
const corsOptions = serverSettings.cors === true ? {} : serverSettings.cors;
|
|
244
|
-
return handlePreflightRequest(req, corsOptions);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// 1. 정적 파일 서빙 시도 (최우선)
|
|
248
|
-
const staticResponse = await serveStaticFile(pathname);
|
|
249
|
-
if (staticResponse) {
|
|
250
|
-
// 정적 파일에도 CORS 헤더 적용
|
|
251
|
-
if (serverSettings.cors && isCorsRequest(req)) {
|
|
252
|
-
const corsOptions = serverSettings.cors === true ? {} : serverSettings.cors;
|
|
253
|
-
return applyCorsToResponse(staticResponse, req, corsOptions);
|
|
254
|
-
}
|
|
255
|
-
return staticResponse;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// 2. 라우트 매칭
|
|
259
|
-
const match = router.match(pathname);
|
|
260
|
-
|
|
261
|
-
if (!match) {
|
|
262
|
-
const error = createNotFoundResponse(pathname);
|
|
263
|
-
const response = formatErrorResponse(error, {
|
|
264
|
-
isDev: process.env.NODE_ENV !== "production",
|
|
265
|
-
});
|
|
266
|
-
return Response.json(response, { status: 404 });
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const { route, params } = match;
|
|
270
|
-
|
|
271
|
-
if (route.kind === "api") {
|
|
272
|
-
const handler = apiHandlers.get(route.id);
|
|
273
|
-
if (!handler) {
|
|
274
|
-
const error = createHandlerNotFoundResponse(route.id, route.pattern);
|
|
275
|
-
const response = formatErrorResponse(error, {
|
|
276
|
-
isDev: process.env.NODE_ENV !== "production",
|
|
277
|
-
});
|
|
278
|
-
return Response.json(response, { status: 500 });
|
|
279
|
-
}
|
|
280
|
-
return handler(req, params);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (route.kind === "page") {
|
|
284
|
-
const loader = pageLoaders.get(route.id);
|
|
285
|
-
if (loader) {
|
|
286
|
-
try {
|
|
287
|
-
const module = await loader();
|
|
288
|
-
registerRouteComponent(route.id, module.default);
|
|
289
|
-
} catch (err) {
|
|
290
|
-
const pageError = createPageLoadErrorResponse(
|
|
291
|
-
route.id,
|
|
292
|
-
route.pattern,
|
|
293
|
-
err instanceof Error ? err : new Error(String(err))
|
|
294
|
-
);
|
|
295
|
-
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
296
|
-
const response = formatErrorResponse(pageError, {
|
|
297
|
-
isDev: process.env.NODE_ENV !== "production",
|
|
298
|
-
});
|
|
299
|
-
return Response.json(response, { status: 500 });
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const appCreator = createAppFn || defaultCreateApp;
|
|
304
|
-
try {
|
|
305
|
-
const app = appCreator({
|
|
306
|
-
routeId: route.id,
|
|
307
|
-
url: req.url,
|
|
308
|
-
params,
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
return renderSSR(app, {
|
|
312
|
-
title: `${route.id} - Mandu`,
|
|
313
|
-
isDev: serverSettings.isDev,
|
|
314
|
-
hmrPort: serverSettings.hmrPort,
|
|
315
|
-
routeId: route.id,
|
|
316
|
-
hydration: route.hydration,
|
|
317
|
-
bundleManifest: serverSettings.bundleManifest,
|
|
318
|
-
});
|
|
319
|
-
} catch (err) {
|
|
320
|
-
const ssrError = createSSRErrorResponse(
|
|
321
|
-
route.id,
|
|
322
|
-
route.pattern,
|
|
323
|
-
err instanceof Error ? err : new Error(String(err))
|
|
324
|
-
);
|
|
325
|
-
console.error(`[Mandu] ${ssrError.errorType}:`, ssrError.message);
|
|
326
|
-
const response = formatErrorResponse(ssrError, {
|
|
327
|
-
isDev: process.env.NODE_ENV !== "production",
|
|
328
|
-
});
|
|
329
|
-
return Response.json(response, { status: 500 });
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return Response.json({
|
|
334
|
-
errorType: "FRAMEWORK_BUG",
|
|
335
|
-
code: "MANDU_F003",
|
|
336
|
-
message: `Unknown route kind: ${route.kind}`,
|
|
337
|
-
summary: "알 수 없는 라우트 종류 - 프레임워크 버그",
|
|
338
|
-
fix: {
|
|
339
|
-
file: "spec/routes.manifest.json",
|
|
340
|
-
suggestion: "라우트의 kind는 'api' 또는 'page'여야 합니다",
|
|
341
|
-
},
|
|
342
|
-
route: {
|
|
343
|
-
id: route.id,
|
|
344
|
-
pattern: route.pattern,
|
|
345
|
-
},
|
|
346
|
-
timestamp: new Date().toISOString(),
|
|
347
|
-
}, { status: 500 });
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// ========== Server Startup ==========
|
|
351
|
-
|
|
352
|
-
export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
|
|
353
|
-
const {
|
|
354
|
-
port = 3000,
|
|
355
|
-
hostname = "localhost",
|
|
356
|
-
rootDir = process.cwd(),
|
|
357
|
-
isDev = false,
|
|
358
|
-
hmrPort,
|
|
359
|
-
bundleManifest,
|
|
360
|
-
publicDir = "public",
|
|
361
|
-
cors = false,
|
|
362
|
-
} = options;
|
|
363
|
-
|
|
364
|
-
// CORS 옵션 파싱
|
|
365
|
-
const corsOptions: CorsOptions | false = cors === true ? {} : cors;
|
|
366
|
-
|
|
367
|
-
// Server settings 저장
|
|
368
|
-
serverSettings = {
|
|
369
|
-
isDev,
|
|
370
|
-
hmrPort,
|
|
371
|
-
bundleManifest,
|
|
372
|
-
rootDir,
|
|
373
|
-
publicDir,
|
|
374
|
-
cors: corsOptions,
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
const router = new Router(manifest.routes);
|
|
378
|
-
|
|
379
|
-
// Fetch handler with CORS support
|
|
380
|
-
const fetchHandler = async (req: Request): Promise<Response> => {
|
|
381
|
-
const response = await handleRequest(req, router);
|
|
382
|
-
|
|
383
|
-
// API 라우트 응답에 CORS 헤더 적용
|
|
384
|
-
if (corsOptions && isCorsRequest(req)) {
|
|
385
|
-
return applyCorsToResponse(response, req, corsOptions);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
return response;
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
const server = Bun.serve({
|
|
392
|
-
port,
|
|
393
|
-
hostname,
|
|
394
|
-
fetch: fetchHandler,
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
if (isDev) {
|
|
398
|
-
console.log(`🥟 Mandu Dev Server running at http://${hostname}:${port}`);
|
|
399
|
-
if (hmrPort) {
|
|
400
|
-
console.log(`🔥 HMR enabled on port ${hmrPort + 1}`);
|
|
401
|
-
}
|
|
402
|
-
console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
|
|
403
|
-
if (corsOptions) {
|
|
404
|
-
console.log(`🌐 CORS enabled`);
|
|
405
|
-
}
|
|
406
|
-
} else {
|
|
407
|
-
console.log(`🥟 Mandu server running at http://${hostname}:${port}`);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return {
|
|
411
|
-
server,
|
|
412
|
-
router,
|
|
413
|
-
stop: () => server.stop(),
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Clear registries (useful for testing)
|
|
418
|
-
export function clearRegistry(): void {
|
|
419
|
-
apiHandlers.clear();
|
|
420
|
-
pageLoaders.clear();
|
|
421
|
-
routeComponents.clear();
|
|
422
|
-
createAppFn = null;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
export { apiHandlers, pageLoaders, routeComponents };
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
3
|
+
import type { BundleManifest } from "../bundler/types";
|
|
4
|
+
import { Router } from "./router";
|
|
5
|
+
import { renderSSR } from "./ssr";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import {
|
|
9
|
+
formatErrorResponse,
|
|
10
|
+
createNotFoundResponse,
|
|
11
|
+
createHandlerNotFoundResponse,
|
|
12
|
+
createPageLoadErrorResponse,
|
|
13
|
+
createSSRErrorResponse,
|
|
14
|
+
} from "../error";
|
|
15
|
+
import {
|
|
16
|
+
type CorsOptions,
|
|
17
|
+
isPreflightRequest,
|
|
18
|
+
handlePreflightRequest,
|
|
19
|
+
applyCorsToResponse,
|
|
20
|
+
isCorsRequest,
|
|
21
|
+
} from "./cors";
|
|
22
|
+
|
|
23
|
+
// ========== MIME Types ==========
|
|
24
|
+
const MIME_TYPES: Record<string, string> = {
|
|
25
|
+
// JavaScript
|
|
26
|
+
".js": "application/javascript",
|
|
27
|
+
".mjs": "application/javascript",
|
|
28
|
+
".ts": "application/typescript",
|
|
29
|
+
// CSS
|
|
30
|
+
".css": "text/css",
|
|
31
|
+
// HTML
|
|
32
|
+
".html": "text/html",
|
|
33
|
+
".htm": "text/html",
|
|
34
|
+
// JSON
|
|
35
|
+
".json": "application/json",
|
|
36
|
+
// Images
|
|
37
|
+
".png": "image/png",
|
|
38
|
+
".jpg": "image/jpeg",
|
|
39
|
+
".jpeg": "image/jpeg",
|
|
40
|
+
".gif": "image/gif",
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".ico": "image/x-icon",
|
|
43
|
+
".webp": "image/webp",
|
|
44
|
+
".avif": "image/avif",
|
|
45
|
+
// Fonts
|
|
46
|
+
".woff": "font/woff",
|
|
47
|
+
".woff2": "font/woff2",
|
|
48
|
+
".ttf": "font/ttf",
|
|
49
|
+
".otf": "font/otf",
|
|
50
|
+
".eot": "application/vnd.ms-fontobject",
|
|
51
|
+
// Documents
|
|
52
|
+
".pdf": "application/pdf",
|
|
53
|
+
".txt": "text/plain",
|
|
54
|
+
".xml": "application/xml",
|
|
55
|
+
// Media
|
|
56
|
+
".mp3": "audio/mpeg",
|
|
57
|
+
".mp4": "video/mp4",
|
|
58
|
+
".webm": "video/webm",
|
|
59
|
+
".ogg": "audio/ogg",
|
|
60
|
+
// Archives
|
|
61
|
+
".zip": "application/zip",
|
|
62
|
+
".gz": "application/gzip",
|
|
63
|
+
// WebAssembly
|
|
64
|
+
".wasm": "application/wasm",
|
|
65
|
+
// Source maps
|
|
66
|
+
".map": "application/json",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function getMimeType(filePath: string): string {
|
|
70
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
71
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ========== Server Options ==========
|
|
75
|
+
export interface ServerOptions {
|
|
76
|
+
port?: number;
|
|
77
|
+
hostname?: string;
|
|
78
|
+
/** 프로젝트 루트 디렉토리 */
|
|
79
|
+
rootDir?: string;
|
|
80
|
+
/** 개발 모드 여부 */
|
|
81
|
+
isDev?: boolean;
|
|
82
|
+
/** HMR 포트 (개발 모드에서 사용) */
|
|
83
|
+
hmrPort?: number;
|
|
84
|
+
/** 번들 매니페스트 (Island hydration용) */
|
|
85
|
+
bundleManifest?: BundleManifest;
|
|
86
|
+
/** Public 디렉토리 경로 (기본: 'public') */
|
|
87
|
+
publicDir?: string;
|
|
88
|
+
/**
|
|
89
|
+
* CORS 설정
|
|
90
|
+
* - true: 모든 Origin 허용
|
|
91
|
+
* - false: CORS 비활성화 (기본값)
|
|
92
|
+
* - CorsOptions: 세부 설정
|
|
93
|
+
*/
|
|
94
|
+
cors?: boolean | CorsOptions;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ManduServer {
|
|
98
|
+
server: Server;
|
|
99
|
+
router: Router;
|
|
100
|
+
stop: () => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
|
|
104
|
+
export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
|
|
105
|
+
|
|
106
|
+
export interface AppContext {
|
|
107
|
+
routeId: string;
|
|
108
|
+
url: string;
|
|
109
|
+
params: Record<string, string>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type RouteComponent = (props: { params: Record<string, string> }) => React.ReactElement;
|
|
113
|
+
type CreateAppFn = (context: AppContext) => React.ReactElement;
|
|
114
|
+
|
|
115
|
+
// Registry
|
|
116
|
+
const apiHandlers: Map<string, ApiHandler> = new Map();
|
|
117
|
+
const pageLoaders: Map<string, PageLoader> = new Map();
|
|
118
|
+
const routeComponents: Map<string, RouteComponent> = new Map();
|
|
119
|
+
let createAppFn: CreateAppFn | null = null;
|
|
120
|
+
|
|
121
|
+
// Server settings (module-level for handleRequest access)
|
|
122
|
+
let serverSettings: {
|
|
123
|
+
isDev: boolean;
|
|
124
|
+
hmrPort?: number;
|
|
125
|
+
bundleManifest?: BundleManifest;
|
|
126
|
+
rootDir: string;
|
|
127
|
+
publicDir: string;
|
|
128
|
+
cors?: CorsOptions | false;
|
|
129
|
+
} = {
|
|
130
|
+
isDev: false,
|
|
131
|
+
rootDir: process.cwd(),
|
|
132
|
+
publicDir: "public",
|
|
133
|
+
cors: false,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function registerApiHandler(routeId: string, handler: ApiHandler): void {
|
|
137
|
+
apiHandlers.set(routeId, handler);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function registerPageLoader(routeId: string, loader: PageLoader): void {
|
|
141
|
+
pageLoaders.set(routeId, loader);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function registerRouteComponent(routeId: string, component: RouteComponent): void {
|
|
145
|
+
routeComponents.set(routeId, component);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function setCreateApp(fn: CreateAppFn): void {
|
|
149
|
+
createAppFn = fn;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Default createApp implementation
|
|
153
|
+
function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
154
|
+
const Component = routeComponents.get(context.routeId);
|
|
155
|
+
|
|
156
|
+
if (!Component) {
|
|
157
|
+
return React.createElement("div", null,
|
|
158
|
+
React.createElement("h1", null, "404 - Route Not Found"),
|
|
159
|
+
React.createElement("p", null, `Route ID: ${context.routeId}`)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return React.createElement(Component, { params: context.params });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ========== Static File Serving ==========
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 정적 파일 서빙
|
|
170
|
+
* - /.mandu/client/* : 클라이언트 번들 (Island hydration)
|
|
171
|
+
* - /public/* : 정적 에셋 (이미지, CSS 등)
|
|
172
|
+
* - /favicon.ico : 파비콘
|
|
173
|
+
*/
|
|
174
|
+
async function serveStaticFile(pathname: string): Promise<Response | null> {
|
|
175
|
+
let filePath: string | null = null;
|
|
176
|
+
let isBundleFile = false;
|
|
177
|
+
|
|
178
|
+
// 1. 클라이언트 번들 파일 (/.mandu/client/*)
|
|
179
|
+
if (pathname.startsWith("/.mandu/client/")) {
|
|
180
|
+
filePath = path.join(serverSettings.rootDir, pathname);
|
|
181
|
+
isBundleFile = true;
|
|
182
|
+
}
|
|
183
|
+
// 2. Public 폴더 파일 (/public/* 또는 직접 접근)
|
|
184
|
+
else if (pathname.startsWith("/public/")) {
|
|
185
|
+
filePath = path.join(serverSettings.rootDir, pathname);
|
|
186
|
+
}
|
|
187
|
+
// 3. Public 폴더의 루트 파일 (favicon.ico, robots.txt 등)
|
|
188
|
+
else if (
|
|
189
|
+
pathname === "/favicon.ico" ||
|
|
190
|
+
pathname === "/robots.txt" ||
|
|
191
|
+
pathname === "/sitemap.xml" ||
|
|
192
|
+
pathname === "/manifest.json"
|
|
193
|
+
) {
|
|
194
|
+
filePath = path.join(serverSettings.rootDir, serverSettings.publicDir, pathname);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!filePath) {
|
|
198
|
+
return null; // 정적 파일이 아님
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const file = Bun.file(filePath);
|
|
203
|
+
const exists = await file.exists();
|
|
204
|
+
|
|
205
|
+
if (!exists) {
|
|
206
|
+
return null; // 파일 없음 - 라우트 매칭으로 넘김
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const mimeType = getMimeType(filePath);
|
|
210
|
+
|
|
211
|
+
// Cache-Control 헤더 설정
|
|
212
|
+
let cacheControl: string;
|
|
213
|
+
if (serverSettings.isDev) {
|
|
214
|
+
// 개발 모드: 캐시 없음
|
|
215
|
+
cacheControl = "no-cache, no-store, must-revalidate";
|
|
216
|
+
} else if (isBundleFile) {
|
|
217
|
+
// 프로덕션 번들: 1년 캐시 (파일명에 해시 포함 가정)
|
|
218
|
+
cacheControl = "public, max-age=31536000, immutable";
|
|
219
|
+
} else {
|
|
220
|
+
// 프로덕션 일반 정적 파일: 1일 캐시
|
|
221
|
+
cacheControl = "public, max-age=86400";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return new Response(file, {
|
|
225
|
+
headers: {
|
|
226
|
+
"Content-Type": mimeType,
|
|
227
|
+
"Cache-Control": cacheControl,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
} catch {
|
|
231
|
+
return null; // 파일 읽기 실패 - 라우트 매칭으로 넘김
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ========== Request Handler ==========
|
|
236
|
+
|
|
237
|
+
async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
238
|
+
const url = new URL(req.url);
|
|
239
|
+
const pathname = url.pathname;
|
|
240
|
+
|
|
241
|
+
// 0. CORS Preflight 요청 처리
|
|
242
|
+
if (serverSettings.cors && isPreflightRequest(req)) {
|
|
243
|
+
const corsOptions = serverSettings.cors === true ? {} : serverSettings.cors;
|
|
244
|
+
return handlePreflightRequest(req, corsOptions);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 1. 정적 파일 서빙 시도 (최우선)
|
|
248
|
+
const staticResponse = await serveStaticFile(pathname);
|
|
249
|
+
if (staticResponse) {
|
|
250
|
+
// 정적 파일에도 CORS 헤더 적용
|
|
251
|
+
if (serverSettings.cors && isCorsRequest(req)) {
|
|
252
|
+
const corsOptions = serverSettings.cors === true ? {} : serverSettings.cors;
|
|
253
|
+
return applyCorsToResponse(staticResponse, req, corsOptions);
|
|
254
|
+
}
|
|
255
|
+
return staticResponse;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 2. 라우트 매칭
|
|
259
|
+
const match = router.match(pathname);
|
|
260
|
+
|
|
261
|
+
if (!match) {
|
|
262
|
+
const error = createNotFoundResponse(pathname);
|
|
263
|
+
const response = formatErrorResponse(error, {
|
|
264
|
+
isDev: process.env.NODE_ENV !== "production",
|
|
265
|
+
});
|
|
266
|
+
return Response.json(response, { status: 404 });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { route, params } = match;
|
|
270
|
+
|
|
271
|
+
if (route.kind === "api") {
|
|
272
|
+
const handler = apiHandlers.get(route.id);
|
|
273
|
+
if (!handler) {
|
|
274
|
+
const error = createHandlerNotFoundResponse(route.id, route.pattern);
|
|
275
|
+
const response = formatErrorResponse(error, {
|
|
276
|
+
isDev: process.env.NODE_ENV !== "production",
|
|
277
|
+
});
|
|
278
|
+
return Response.json(response, { status: 500 });
|
|
279
|
+
}
|
|
280
|
+
return handler(req, params);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (route.kind === "page") {
|
|
284
|
+
const loader = pageLoaders.get(route.id);
|
|
285
|
+
if (loader) {
|
|
286
|
+
try {
|
|
287
|
+
const module = await loader();
|
|
288
|
+
registerRouteComponent(route.id, module.default);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
const pageError = createPageLoadErrorResponse(
|
|
291
|
+
route.id,
|
|
292
|
+
route.pattern,
|
|
293
|
+
err instanceof Error ? err : new Error(String(err))
|
|
294
|
+
);
|
|
295
|
+
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
296
|
+
const response = formatErrorResponse(pageError, {
|
|
297
|
+
isDev: process.env.NODE_ENV !== "production",
|
|
298
|
+
});
|
|
299
|
+
return Response.json(response, { status: 500 });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const appCreator = createAppFn || defaultCreateApp;
|
|
304
|
+
try {
|
|
305
|
+
const app = appCreator({
|
|
306
|
+
routeId: route.id,
|
|
307
|
+
url: req.url,
|
|
308
|
+
params,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return renderSSR(app, {
|
|
312
|
+
title: `${route.id} - Mandu`,
|
|
313
|
+
isDev: serverSettings.isDev,
|
|
314
|
+
hmrPort: serverSettings.hmrPort,
|
|
315
|
+
routeId: route.id,
|
|
316
|
+
hydration: route.hydration,
|
|
317
|
+
bundleManifest: serverSettings.bundleManifest,
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
const ssrError = createSSRErrorResponse(
|
|
321
|
+
route.id,
|
|
322
|
+
route.pattern,
|
|
323
|
+
err instanceof Error ? err : new Error(String(err))
|
|
324
|
+
);
|
|
325
|
+
console.error(`[Mandu] ${ssrError.errorType}:`, ssrError.message);
|
|
326
|
+
const response = formatErrorResponse(ssrError, {
|
|
327
|
+
isDev: process.env.NODE_ENV !== "production",
|
|
328
|
+
});
|
|
329
|
+
return Response.json(response, { status: 500 });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return Response.json({
|
|
334
|
+
errorType: "FRAMEWORK_BUG",
|
|
335
|
+
code: "MANDU_F003",
|
|
336
|
+
message: `Unknown route kind: ${route.kind}`,
|
|
337
|
+
summary: "알 수 없는 라우트 종류 - 프레임워크 버그",
|
|
338
|
+
fix: {
|
|
339
|
+
file: "spec/routes.manifest.json",
|
|
340
|
+
suggestion: "라우트의 kind는 'api' 또는 'page'여야 합니다",
|
|
341
|
+
},
|
|
342
|
+
route: {
|
|
343
|
+
id: route.id,
|
|
344
|
+
pattern: route.pattern,
|
|
345
|
+
},
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
}, { status: 500 });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ========== Server Startup ==========
|
|
351
|
+
|
|
352
|
+
export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
|
|
353
|
+
const {
|
|
354
|
+
port = 3000,
|
|
355
|
+
hostname = "localhost",
|
|
356
|
+
rootDir = process.cwd(),
|
|
357
|
+
isDev = false,
|
|
358
|
+
hmrPort,
|
|
359
|
+
bundleManifest,
|
|
360
|
+
publicDir = "public",
|
|
361
|
+
cors = false,
|
|
362
|
+
} = options;
|
|
363
|
+
|
|
364
|
+
// CORS 옵션 파싱
|
|
365
|
+
const corsOptions: CorsOptions | false = cors === true ? {} : cors;
|
|
366
|
+
|
|
367
|
+
// Server settings 저장
|
|
368
|
+
serverSettings = {
|
|
369
|
+
isDev,
|
|
370
|
+
hmrPort,
|
|
371
|
+
bundleManifest,
|
|
372
|
+
rootDir,
|
|
373
|
+
publicDir,
|
|
374
|
+
cors: corsOptions,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const router = new Router(manifest.routes);
|
|
378
|
+
|
|
379
|
+
// Fetch handler with CORS support
|
|
380
|
+
const fetchHandler = async (req: Request): Promise<Response> => {
|
|
381
|
+
const response = await handleRequest(req, router);
|
|
382
|
+
|
|
383
|
+
// API 라우트 응답에 CORS 헤더 적용
|
|
384
|
+
if (corsOptions && isCorsRequest(req)) {
|
|
385
|
+
return applyCorsToResponse(response, req, corsOptions);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return response;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const server = Bun.serve({
|
|
392
|
+
port,
|
|
393
|
+
hostname,
|
|
394
|
+
fetch: fetchHandler,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (isDev) {
|
|
398
|
+
console.log(`🥟 Mandu Dev Server running at http://${hostname}:${port}`);
|
|
399
|
+
if (hmrPort) {
|
|
400
|
+
console.log(`🔥 HMR enabled on port ${hmrPort + 1}`);
|
|
401
|
+
}
|
|
402
|
+
console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
|
|
403
|
+
if (corsOptions) {
|
|
404
|
+
console.log(`🌐 CORS enabled`);
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
console.log(`🥟 Mandu server running at http://${hostname}:${port}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
server,
|
|
412
|
+
router,
|
|
413
|
+
stop: () => server.stop(),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Clear registries (useful for testing)
|
|
418
|
+
export function clearRegistry(): void {
|
|
419
|
+
apiHandlers.clear();
|
|
420
|
+
pageLoaders.clear();
|
|
421
|
+
routeComponents.clear();
|
|
422
|
+
createAppFn = null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export { apiHandlers, pageLoaders, routeComponents };
|