@mandujs/core 0.5.1 → 0.5.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.
@@ -4,6 +4,7 @@ import type { BundleManifest } from "../bundler/types";
4
4
  import { Router } from "./router";
5
5
  import { renderSSR } from "./ssr";
6
6
  import React from "react";
7
+ import path from "path";
7
8
  import {
8
9
  formatErrorResponse,
9
10
  createNotFoundResponse,
@@ -11,16 +12,86 @@ import {
11
12
  createPageLoadErrorResponse,
12
13
  createSSRErrorResponse,
13
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
+ };
14
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 ==========
15
75
  export interface ServerOptions {
16
76
  port?: number;
17
77
  hostname?: string;
78
+ /** 프로젝트 루트 디렉토리 */
79
+ rootDir?: string;
18
80
  /** 개발 모드 여부 */
19
81
  isDev?: boolean;
20
82
  /** HMR 포트 (개발 모드에서 사용) */
21
83
  hmrPort?: number;
22
84
  /** 번들 매니페스트 (Island hydration용) */
23
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;
24
95
  }
25
96
 
26
97
  export interface ManduServer {
@@ -47,8 +118,20 @@ const pageLoaders: Map<string, PageLoader> = new Map();
47
118
  const routeComponents: Map<string, RouteComponent> = new Map();
48
119
  let createAppFn: CreateAppFn | null = null;
49
120
 
50
- // Dev mode settings (module-level for handleRequest access)
51
- let devModeSettings: { isDev: boolean; hmrPort?: number; bundleManifest?: BundleManifest } = { isDev: false };
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
+ };
52
135
 
53
136
  export function registerApiHandler(routeId: string, handler: ApiHandler): void {
54
137
  apiHandlers.set(routeId, handler);
@@ -80,10 +163,99 @@ function defaultCreateApp(context: AppContext): React.ReactElement {
80
163
  return React.createElement(Component, { params: context.params });
81
164
  }
82
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
+
83
237
  async function handleRequest(req: Request, router: Router): Promise<Response> {
84
238
  const url = new URL(req.url);
85
239
  const pathname = url.pathname;
86
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. 라우트 매칭
87
259
  const match = router.match(pathname);
88
260
 
89
261
  if (!match) {
@@ -138,11 +310,11 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
138
310
 
139
311
  return renderSSR(app, {
140
312
  title: `${route.id} - Mandu`,
141
- isDev: devModeSettings.isDev,
142
- hmrPort: devModeSettings.hmrPort,
313
+ isDev: serverSettings.isDev,
314
+ hmrPort: serverSettings.hmrPort,
143
315
  routeId: route.id,
144
316
  hydration: route.hydration,
145
- bundleManifest: devModeSettings.bundleManifest,
317
+ bundleManifest: serverSettings.bundleManifest,
146
318
  });
147
319
  } catch (err) {
148
320
  const ssrError = createSSRErrorResponse(
@@ -175,18 +347,51 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
175
347
  }, { status: 500 });
176
348
  }
177
349
 
350
+ // ========== Server Startup ==========
351
+
178
352
  export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
179
- const { port = 3000, hostname = "localhost", isDev = false, hmrPort, bundleManifest } = options;
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;
180
366
 
181
- // Dev mode settings 저장
182
- devModeSettings = { isDev, hmrPort, bundleManifest };
367
+ // Server settings 저장
368
+ serverSettings = {
369
+ isDev,
370
+ hmrPort,
371
+ bundleManifest,
372
+ rootDir,
373
+ publicDir,
374
+ cors: corsOptions,
375
+ };
183
376
 
184
377
  const router = new Router(manifest.routes);
185
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
+
186
391
  const server = Bun.serve({
187
392
  port,
188
393
  hostname,
189
- fetch: (req) => handleRequest(req, router),
394
+ fetch: fetchHandler,
190
395
  });
191
396
 
192
397
  if (isDev) {
@@ -194,6 +399,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
194
399
  if (hmrPort) {
195
400
  console.log(`🔥 HMR enabled on port ${hmrPort + 1}`);
196
401
  }
402
+ console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
403
+ if (corsOptions) {
404
+ console.log(`🌐 CORS enabled`);
405
+ }
197
406
  } else {
198
407
  console.log(`🥟 Mandu server running at http://${hostname}:${port}`);
199
408
  }
@@ -39,6 +39,18 @@ function serializeServerData(data: Record<string, unknown>): string {
39
39
  <script>window.__MANDU_DATA__ = JSON.parse(document.getElementById('__MANDU_DATA__').textContent);</script>`;
40
40
  }
41
41
 
42
+ /**
43
+ * Import map 생성 (bare specifier 해결용)
44
+ */
45
+ function generateImportMap(manifest: BundleManifest): string {
46
+ if (!manifest.importMap || Object.keys(manifest.importMap.imports).length === 0) {
47
+ return "";
48
+ }
49
+
50
+ const importMapJson = JSON.stringify(manifest.importMap, null, 2);
51
+ return `<script type="importmap">${importMapJson}</script>`;
52
+ }
53
+
42
54
  /**
43
55
  * Hydration 스크립트 태그 생성
44
56
  */
@@ -48,16 +60,17 @@ function generateHydrationScripts(
48
60
  ): string {
49
61
  const scripts: string[] = [];
50
62
 
63
+ // Import map 먼저 (반드시 module scripts 전에 위치해야 함)
64
+ const importMap = generateImportMap(manifest);
65
+ if (importMap) {
66
+ scripts.push(importMap);
67
+ }
68
+
51
69
  // Runtime 로드
52
70
  if (manifest.shared.runtime) {
53
71
  scripts.push(`<script type="module" src="${manifest.shared.runtime}"></script>`);
54
72
  }
55
73
 
56
- // Vendor 로드
57
- if (manifest.shared.vendor) {
58
- scripts.push(`<script type="module" src="${manifest.shared.vendor}"></script>`);
59
- }
60
-
61
74
  // Island 번들 로드
62
75
  const bundle = manifest.bundles[routeId];
63
76
  if (bundle) {