@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.
- package/package.json +1 -1
- package/src/bundler/build.ts +114 -57
- package/src/bundler/types.ts +5 -1
- package/src/client/index.ts +9 -1
- package/src/client/island.ts +59 -0
- package/src/filling/context.ts +235 -5
- package/src/filling/index.ts +2 -1
- package/src/runtime/cors.ts +277 -0
- package/src/runtime/env.ts +386 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/server.ts +218 -9
- package/src/runtime/ssr.ts +18 -5
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
-
//
|
|
51
|
-
let
|
|
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:
|
|
142
|
-
hmrPort:
|
|
313
|
+
isDev: serverSettings.isDev,
|
|
314
|
+
hmrPort: serverSettings.hmrPort,
|
|
143
315
|
routeId: route.id,
|
|
144
316
|
hydration: route.hydration,
|
|
145
|
-
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 {
|
|
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
|
-
//
|
|
182
|
-
|
|
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:
|
|
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
|
}
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -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) {
|