@mandujs/core 0.5.0 → 0.5.2
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/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 +223 -8
package/package.json
CHANGED
package/src/filling/context.ts
CHANGED
|
@@ -5,12 +5,211 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ZodSchema } from "zod";
|
|
7
7
|
|
|
8
|
+
// ========== Cookie Types ==========
|
|
9
|
+
|
|
10
|
+
export interface CookieOptions {
|
|
11
|
+
/** 쿠키 만료 시간 (Date 객체 또는 문자열) */
|
|
12
|
+
expires?: Date | string;
|
|
13
|
+
/** 쿠키 유효 기간 (초) */
|
|
14
|
+
maxAge?: number;
|
|
15
|
+
/** 쿠키 도메인 */
|
|
16
|
+
domain?: string;
|
|
17
|
+
/** 쿠키 경로 */
|
|
18
|
+
path?: string;
|
|
19
|
+
/** HTTPS에서만 전송 */
|
|
20
|
+
secure?: boolean;
|
|
21
|
+
/** JavaScript에서 접근 불가 */
|
|
22
|
+
httpOnly?: boolean;
|
|
23
|
+
/** Same-Site 정책 */
|
|
24
|
+
sameSite?: "strict" | "lax" | "none";
|
|
25
|
+
/** 파티션 키 (CHIPS) */
|
|
26
|
+
partitioned?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Cookie Manager - 쿠키 읽기/쓰기 관리
|
|
31
|
+
*/
|
|
32
|
+
export class CookieManager {
|
|
33
|
+
private requestCookies: Map<string, string>;
|
|
34
|
+
private responseCookies: Map<string, { value: string; options: CookieOptions }>;
|
|
35
|
+
private deletedCookies: Set<string>;
|
|
36
|
+
|
|
37
|
+
constructor(request: Request) {
|
|
38
|
+
this.requestCookies = this.parseRequestCookies(request);
|
|
39
|
+
this.responseCookies = new Map();
|
|
40
|
+
this.deletedCookies = new Set();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private parseRequestCookies(request: Request): Map<string, string> {
|
|
44
|
+
const cookies = new Map<string, string>();
|
|
45
|
+
const cookieHeader = request.headers.get("cookie");
|
|
46
|
+
|
|
47
|
+
if (cookieHeader) {
|
|
48
|
+
const pairs = cookieHeader.split(";");
|
|
49
|
+
for (const pair of pairs) {
|
|
50
|
+
const [name, ...rest] = pair.trim().split("=");
|
|
51
|
+
if (name) {
|
|
52
|
+
cookies.set(name, decodeURIComponent(rest.join("=")));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return cookies;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 쿠키 값 읽기
|
|
62
|
+
* @example
|
|
63
|
+
* const session = ctx.cookies.get('session');
|
|
64
|
+
*/
|
|
65
|
+
get(name: string): string | undefined {
|
|
66
|
+
return this.requestCookies.get(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 쿠키 존재 여부 확인
|
|
71
|
+
*/
|
|
72
|
+
has(name: string): boolean {
|
|
73
|
+
return this.requestCookies.has(name);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 모든 쿠키 가져오기
|
|
78
|
+
*/
|
|
79
|
+
getAll(): Record<string, string> {
|
|
80
|
+
return Object.fromEntries(this.requestCookies);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 쿠키 설정
|
|
85
|
+
* @example
|
|
86
|
+
* ctx.cookies.set('session', 'abc123', { httpOnly: true, maxAge: 3600 });
|
|
87
|
+
*/
|
|
88
|
+
set(name: string, value: string, options: CookieOptions = {}): void {
|
|
89
|
+
this.responseCookies.set(name, { value, options });
|
|
90
|
+
this.deletedCookies.delete(name);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 쿠키 삭제
|
|
95
|
+
* @example
|
|
96
|
+
* ctx.cookies.delete('session');
|
|
97
|
+
*/
|
|
98
|
+
delete(name: string, options: Pick<CookieOptions, "domain" | "path"> = {}): void {
|
|
99
|
+
this.responseCookies.delete(name);
|
|
100
|
+
this.deletedCookies.add(name);
|
|
101
|
+
// 삭제용 쿠키 설정 (maxAge=0)
|
|
102
|
+
this.responseCookies.set(name, {
|
|
103
|
+
value: "",
|
|
104
|
+
options: {
|
|
105
|
+
...options,
|
|
106
|
+
maxAge: 0,
|
|
107
|
+
expires: new Date(0),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Set-Cookie 헤더 값들 생성
|
|
114
|
+
*/
|
|
115
|
+
getSetCookieHeaders(): string[] {
|
|
116
|
+
const headers: string[] = [];
|
|
117
|
+
|
|
118
|
+
for (const [name, { value, options }] of this.responseCookies) {
|
|
119
|
+
headers.push(this.serializeCookie(name, value, options));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return headers;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 쿠키를 Set-Cookie 헤더 형식으로 직렬화
|
|
127
|
+
*/
|
|
128
|
+
private serializeCookie(name: string, value: string, options: CookieOptions): string {
|
|
129
|
+
const parts: string[] = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
130
|
+
|
|
131
|
+
if (options.maxAge !== undefined) {
|
|
132
|
+
parts.push(`Max-Age=${options.maxAge}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (options.expires) {
|
|
136
|
+
const expires =
|
|
137
|
+
options.expires instanceof Date
|
|
138
|
+
? options.expires.toUTCString()
|
|
139
|
+
: options.expires;
|
|
140
|
+
parts.push(`Expires=${expires}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (options.domain) {
|
|
144
|
+
parts.push(`Domain=${options.domain}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (options.path) {
|
|
148
|
+
parts.push(`Path=${options.path}`);
|
|
149
|
+
} else {
|
|
150
|
+
parts.push("Path=/"); // 기본값
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (options.secure) {
|
|
154
|
+
parts.push("Secure");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (options.httpOnly) {
|
|
158
|
+
parts.push("HttpOnly");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (options.sameSite) {
|
|
162
|
+
parts.push(`SameSite=${options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (options.partitioned) {
|
|
166
|
+
parts.push("Partitioned");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return parts.join("; ");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Response에 Set-Cookie 헤더들 적용
|
|
174
|
+
*/
|
|
175
|
+
applyToResponse(response: Response): Response {
|
|
176
|
+
const setCookieHeaders = this.getSetCookieHeaders();
|
|
177
|
+
|
|
178
|
+
if (setCookieHeaders.length === 0) {
|
|
179
|
+
return response;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Headers를 복사하여 수정
|
|
183
|
+
const newHeaders = new Headers(response.headers);
|
|
184
|
+
|
|
185
|
+
for (const setCookie of setCookieHeaders) {
|
|
186
|
+
newHeaders.append("Set-Cookie", setCookie);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Response(response.body, {
|
|
190
|
+
status: response.status,
|
|
191
|
+
statusText: response.statusText,
|
|
192
|
+
headers: newHeaders,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 응답에 적용할 쿠키가 있는지 확인
|
|
198
|
+
*/
|
|
199
|
+
hasPendingCookies(): boolean {
|
|
200
|
+
return this.responseCookies.size > 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ========== ManduContext ==========
|
|
205
|
+
|
|
8
206
|
export class ManduContext {
|
|
9
207
|
private store: Map<string, unknown> = new Map();
|
|
10
208
|
private _params: Record<string, string>;
|
|
11
209
|
private _query: Record<string, string>;
|
|
12
210
|
private _shouldContinue: boolean = true;
|
|
13
211
|
private _response: Response | null = null;
|
|
212
|
+
private _cookies: CookieManager;
|
|
14
213
|
|
|
15
214
|
constructor(
|
|
16
215
|
public readonly request: Request,
|
|
@@ -18,6 +217,7 @@ export class ManduContext {
|
|
|
18
217
|
) {
|
|
19
218
|
this._params = params;
|
|
20
219
|
this._query = this.parseQuery();
|
|
220
|
+
this._cookies = new CookieManager(request);
|
|
21
221
|
}
|
|
22
222
|
|
|
23
223
|
private parseQuery(): Record<string, string> {
|
|
@@ -58,6 +258,22 @@ export class ManduContext {
|
|
|
58
258
|
return this.request.url;
|
|
59
259
|
}
|
|
60
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Cookie Manager
|
|
263
|
+
* @example
|
|
264
|
+
* // 쿠키 읽기
|
|
265
|
+
* const session = ctx.cookies.get('session');
|
|
266
|
+
*
|
|
267
|
+
* // 쿠키 설정
|
|
268
|
+
* ctx.cookies.set('session', 'abc123', { httpOnly: true, maxAge: 3600 });
|
|
269
|
+
*
|
|
270
|
+
* // 쿠키 삭제
|
|
271
|
+
* ctx.cookies.delete('session');
|
|
272
|
+
*/
|
|
273
|
+
get cookies(): CookieManager {
|
|
274
|
+
return this._cookies;
|
|
275
|
+
}
|
|
276
|
+
|
|
61
277
|
/**
|
|
62
278
|
* Parse request body with optional Zod validation
|
|
63
279
|
* @example
|
|
@@ -95,6 +311,16 @@ export class ManduContext {
|
|
|
95
311
|
// 🥟 Response 보내기
|
|
96
312
|
// ============================================
|
|
97
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Response에 쿠키 헤더 적용 (내부 사용)
|
|
316
|
+
*/
|
|
317
|
+
private withCookies(response: Response): Response {
|
|
318
|
+
if (this._cookies.hasPendingCookies()) {
|
|
319
|
+
return this._cookies.applyToResponse(response);
|
|
320
|
+
}
|
|
321
|
+
return response;
|
|
322
|
+
}
|
|
323
|
+
|
|
98
324
|
/** 200 OK */
|
|
99
325
|
ok<T>(data: T): Response {
|
|
100
326
|
return this.json(data, 200);
|
|
@@ -107,7 +333,7 @@ export class ManduContext {
|
|
|
107
333
|
|
|
108
334
|
/** 204 No Content */
|
|
109
335
|
noContent(): Response {
|
|
110
|
-
return new Response(null, { status: 204 });
|
|
336
|
+
return this.withCookies(new Response(null, { status: 204 }));
|
|
111
337
|
}
|
|
112
338
|
|
|
113
339
|
/** 400 Bad Request */
|
|
@@ -137,28 +363,32 @@ export class ManduContext {
|
|
|
137
363
|
|
|
138
364
|
/** Custom JSON response */
|
|
139
365
|
json<T>(data: T, status: number = 200): Response {
|
|
140
|
-
|
|
366
|
+
const response = Response.json(data, { status });
|
|
367
|
+
return this.withCookies(response);
|
|
141
368
|
}
|
|
142
369
|
|
|
143
370
|
/** Custom text response */
|
|
144
371
|
text(data: string, status: number = 200): Response {
|
|
145
|
-
|
|
372
|
+
const response = new Response(data, {
|
|
146
373
|
status,
|
|
147
374
|
headers: { "Content-Type": "text/plain" },
|
|
148
375
|
});
|
|
376
|
+
return this.withCookies(response);
|
|
149
377
|
}
|
|
150
378
|
|
|
151
379
|
/** Custom HTML response */
|
|
152
380
|
html(data: string, status: number = 200): Response {
|
|
153
|
-
|
|
381
|
+
const response = new Response(data, {
|
|
154
382
|
status,
|
|
155
383
|
headers: { "Content-Type": "text/html" },
|
|
156
384
|
});
|
|
385
|
+
return this.withCookies(response);
|
|
157
386
|
}
|
|
158
387
|
|
|
159
388
|
/** Redirect response */
|
|
160
389
|
redirect(url: string, status: 301 | 302 | 307 | 308 = 302): Response {
|
|
161
|
-
|
|
390
|
+
const response = Response.redirect(url, status);
|
|
391
|
+
return this.withCookies(response);
|
|
162
392
|
}
|
|
163
393
|
|
|
164
394
|
// ============================================
|
package/src/filling/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Mandu Filling Module - 만두소 🥟
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export { ManduContext, NEXT_SYMBOL, ValidationError } from "./context";
|
|
5
|
+
export { ManduContext, NEXT_SYMBOL, ValidationError, CookieManager } from "./context";
|
|
6
|
+
export type { CookieOptions } from "./context";
|
|
6
7
|
export { ManduFilling, Mandu } from "./filling";
|
|
7
8
|
export type { Handler, Guard, HttpMethod } from "./filling";
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu CORS Support
|
|
3
|
+
*
|
|
4
|
+
* Cross-Origin Resource Sharing (CORS) 미들웨어
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface CorsOptions {
|
|
8
|
+
/**
|
|
9
|
+
* 허용할 Origin 목록
|
|
10
|
+
* - "*" : 모든 Origin 허용
|
|
11
|
+
* - string : 특정 Origin만 허용
|
|
12
|
+
* - string[] : 여러 Origin 허용
|
|
13
|
+
* - RegExp : 정규식으로 Origin 매칭
|
|
14
|
+
* - (origin: string) => boolean : 커스텀 함수로 판단
|
|
15
|
+
*/
|
|
16
|
+
origin?: "*" | string | string[] | RegExp | ((origin: string) => boolean);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 허용할 HTTP 메서드 목록
|
|
20
|
+
* @default ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
|
|
21
|
+
*/
|
|
22
|
+
methods?: string[];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 허용할 요청 헤더 목록
|
|
26
|
+
* @default ["Content-Type", "Authorization", "X-Requested-With"]
|
|
27
|
+
*/
|
|
28
|
+
allowedHeaders?: string[];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 클라이언트에게 노출할 응답 헤더 목록
|
|
32
|
+
*/
|
|
33
|
+
exposedHeaders?: string[];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 자격 증명(쿠키, 인증 헤더) 포함 허용 여부
|
|
37
|
+
* @default false
|
|
38
|
+
*/
|
|
39
|
+
credentials?: boolean;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Preflight 요청 캐시 시간 (초)
|
|
43
|
+
* @default 86400 (24시간)
|
|
44
|
+
*/
|
|
45
|
+
maxAge?: number;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Preflight OPTIONS 요청 자동 처리 여부
|
|
49
|
+
* @default true
|
|
50
|
+
*/
|
|
51
|
+
preflightContinue?: boolean;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* OPTIONS 요청 성공 응답 상태 코드
|
|
55
|
+
* @default 204
|
|
56
|
+
*/
|
|
57
|
+
optionsSuccessStatus?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 기본 CORS 옵션
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_CORS_OPTIONS: Required<CorsOptions> = {
|
|
64
|
+
origin: "*",
|
|
65
|
+
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"],
|
|
66
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
|
67
|
+
exposedHeaders: [],
|
|
68
|
+
credentials: false,
|
|
69
|
+
maxAge: 86400,
|
|
70
|
+
preflightContinue: false,
|
|
71
|
+
optionsSuccessStatus: 204,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Origin 검증
|
|
76
|
+
*/
|
|
77
|
+
function isOriginAllowed(
|
|
78
|
+
requestOrigin: string | null,
|
|
79
|
+
allowedOrigin: CorsOptions["origin"]
|
|
80
|
+
): boolean {
|
|
81
|
+
if (!requestOrigin) return false;
|
|
82
|
+
|
|
83
|
+
if (allowedOrigin === "*") {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof allowedOrigin === "string") {
|
|
88
|
+
return requestOrigin === allowedOrigin;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(allowedOrigin)) {
|
|
92
|
+
return allowedOrigin.includes(requestOrigin);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (allowedOrigin instanceof RegExp) {
|
|
96
|
+
return allowedOrigin.test(requestOrigin);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof allowedOrigin === "function") {
|
|
100
|
+
return allowedOrigin(requestOrigin);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* CORS 헤더 생성
|
|
108
|
+
*/
|
|
109
|
+
export function createCorsHeaders(
|
|
110
|
+
req: Request,
|
|
111
|
+
options: CorsOptions = {}
|
|
112
|
+
): Headers {
|
|
113
|
+
const opts = { ...DEFAULT_CORS_OPTIONS, ...options };
|
|
114
|
+
const headers = new Headers();
|
|
115
|
+
const requestOrigin = req.headers.get("origin");
|
|
116
|
+
|
|
117
|
+
// Access-Control-Allow-Origin
|
|
118
|
+
if (opts.origin === "*" && !opts.credentials) {
|
|
119
|
+
headers.set("Access-Control-Allow-Origin", "*");
|
|
120
|
+
} else if (requestOrigin && isOriginAllowed(requestOrigin, opts.origin)) {
|
|
121
|
+
headers.set("Access-Control-Allow-Origin", requestOrigin);
|
|
122
|
+
headers.set("Vary", "Origin");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Access-Control-Allow-Credentials
|
|
126
|
+
if (opts.credentials) {
|
|
127
|
+
headers.set("Access-Control-Allow-Credentials", "true");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Access-Control-Expose-Headers
|
|
131
|
+
if (opts.exposedHeaders && opts.exposedHeaders.length > 0) {
|
|
132
|
+
headers.set("Access-Control-Expose-Headers", opts.exposedHeaders.join(", "));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return headers;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Preflight 요청 헤더 생성
|
|
140
|
+
*/
|
|
141
|
+
export function createPreflightHeaders(
|
|
142
|
+
req: Request,
|
|
143
|
+
options: CorsOptions = {}
|
|
144
|
+
): Headers {
|
|
145
|
+
const opts = { ...DEFAULT_CORS_OPTIONS, ...options };
|
|
146
|
+
const headers = createCorsHeaders(req, options);
|
|
147
|
+
|
|
148
|
+
// Access-Control-Allow-Methods
|
|
149
|
+
headers.set("Access-Control-Allow-Methods", opts.methods.join(", "));
|
|
150
|
+
|
|
151
|
+
// Access-Control-Allow-Headers
|
|
152
|
+
const requestHeaders = req.headers.get("access-control-request-headers");
|
|
153
|
+
if (requestHeaders) {
|
|
154
|
+
// Echo back requested headers (or use allowedHeaders)
|
|
155
|
+
headers.set("Access-Control-Allow-Headers", requestHeaders);
|
|
156
|
+
} else if (opts.allowedHeaders && opts.allowedHeaders.length > 0) {
|
|
157
|
+
headers.set("Access-Control-Allow-Headers", opts.allowedHeaders.join(", "));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Access-Control-Max-Age
|
|
161
|
+
if (opts.maxAge) {
|
|
162
|
+
headers.set("Access-Control-Max-Age", String(opts.maxAge));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return headers;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Preflight OPTIONS 요청 처리
|
|
170
|
+
*/
|
|
171
|
+
export function handlePreflightRequest(
|
|
172
|
+
req: Request,
|
|
173
|
+
options: CorsOptions = {}
|
|
174
|
+
): Response {
|
|
175
|
+
const opts = { ...DEFAULT_CORS_OPTIONS, ...options };
|
|
176
|
+
const headers = createPreflightHeaders(req, options);
|
|
177
|
+
|
|
178
|
+
return new Response(null, {
|
|
179
|
+
status: opts.optionsSuccessStatus,
|
|
180
|
+
headers,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* CORS 적용된 Response 생성
|
|
186
|
+
*/
|
|
187
|
+
export function applyCorsToResponse(
|
|
188
|
+
response: Response,
|
|
189
|
+
req: Request,
|
|
190
|
+
options: CorsOptions = {}
|
|
191
|
+
): Response {
|
|
192
|
+
const corsHeaders = createCorsHeaders(req, options);
|
|
193
|
+
|
|
194
|
+
// 기존 응답 헤더에 CORS 헤더 추가
|
|
195
|
+
const newHeaders = new Headers(response.headers);
|
|
196
|
+
corsHeaders.forEach((value, key) => {
|
|
197
|
+
newHeaders.set(key, value);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return new Response(response.body, {
|
|
201
|
+
status: response.status,
|
|
202
|
+
statusText: response.statusText,
|
|
203
|
+
headers: newHeaders,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* CORS 검사 (요청이 CORS 요청인지)
|
|
209
|
+
*/
|
|
210
|
+
export function isCorsRequest(req: Request): boolean {
|
|
211
|
+
return req.headers.has("origin");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Preflight 요청인지 확인
|
|
216
|
+
*/
|
|
217
|
+
export function isPreflightRequest(req: Request): boolean {
|
|
218
|
+
return (
|
|
219
|
+
req.method === "OPTIONS" &&
|
|
220
|
+
req.headers.has("origin") &&
|
|
221
|
+
req.headers.has("access-control-request-method")
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 간편 CORS 헬퍼 - Guard에서 사용
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```typescript
|
|
230
|
+
* import { Mandu, cors } from "@mandujs/core";
|
|
231
|
+
*
|
|
232
|
+
* export default Mandu.filling()
|
|
233
|
+
* .guard(cors({ origin: "https://example.com" }))
|
|
234
|
+
* .get((ctx) => ctx.ok({ data: "hello" }));
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
export function cors(options: CorsOptions = {}) {
|
|
238
|
+
return async (ctx: { request: Request; next: () => symbol }) => {
|
|
239
|
+
// Preflight 요청 처리
|
|
240
|
+
if (isPreflightRequest(ctx.request)) {
|
|
241
|
+
return handlePreflightRequest(ctx.request, options);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 일반 요청 - next()로 계속 진행
|
|
245
|
+
return ctx.next();
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* CORS 옵션 프리셋
|
|
251
|
+
*/
|
|
252
|
+
export const corsPresets = {
|
|
253
|
+
/**
|
|
254
|
+
* 모든 Origin 허용 (개발용)
|
|
255
|
+
*/
|
|
256
|
+
development: (): CorsOptions => ({
|
|
257
|
+
origin: "*",
|
|
258
|
+
credentials: false,
|
|
259
|
+
}),
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 특정 도메인만 허용
|
|
263
|
+
*/
|
|
264
|
+
production: (allowedOrigins: string[]): CorsOptions => ({
|
|
265
|
+
origin: allowedOrigins,
|
|
266
|
+
credentials: true,
|
|
267
|
+
maxAge: 86400,
|
|
268
|
+
}),
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 동일 도메인 + 특정 서브도메인 허용
|
|
272
|
+
*/
|
|
273
|
+
sameOriginWithSubdomains: (baseDomain: string): CorsOptions => ({
|
|
274
|
+
origin: new RegExp(`^https?://([a-z0-9-]+\\.)?${baseDomain.replace(".", "\\.")}$`),
|
|
275
|
+
credentials: true,
|
|
276
|
+
}),
|
|
277
|
+
};
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Environment Configuration
|
|
3
|
+
*
|
|
4
|
+
* .env 파일 로딩 및 환경 변수 관리
|
|
5
|
+
* Bun의 내장 .env 지원을 확장하여 환경별 설정 제공
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from "path";
|
|
9
|
+
|
|
10
|
+
// ========== Types ==========
|
|
11
|
+
|
|
12
|
+
export interface EnvConfig {
|
|
13
|
+
/**
|
|
14
|
+
* 프로젝트 루트 디렉토리
|
|
15
|
+
* @default process.cwd()
|
|
16
|
+
*/
|
|
17
|
+
rootDir?: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 환경 이름 (development, production, test 등)
|
|
21
|
+
* @default process.env.NODE_ENV || 'development'
|
|
22
|
+
*/
|
|
23
|
+
env?: string;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* .env 파일 경로 목록 (우선순위 순서)
|
|
27
|
+
* @default ['.env.local', '.env.{env}', '.env']
|
|
28
|
+
*/
|
|
29
|
+
files?: string[];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 필수 환경 변수 목록
|
|
33
|
+
* 없으면 에러 발생
|
|
34
|
+
*/
|
|
35
|
+
required?: string[];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 기본값 설정
|
|
39
|
+
*/
|
|
40
|
+
defaults?: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EnvValidationResult {
|
|
44
|
+
success: boolean;
|
|
45
|
+
loaded: string[];
|
|
46
|
+
missing: string[];
|
|
47
|
+
errors: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ========== Internal Helpers ==========
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* .env 파일 파싱
|
|
54
|
+
*/
|
|
55
|
+
function parseEnvFile(content: string): Record<string, string> {
|
|
56
|
+
const result: Record<string, string> = {};
|
|
57
|
+
const lines = content.split("\n");
|
|
58
|
+
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
// 빈 줄이나 주석 건너뛰기
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// KEY=VALUE 파싱
|
|
67
|
+
const equalIndex = trimmed.indexOf("=");
|
|
68
|
+
if (equalIndex === -1) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const key = trimmed.substring(0, equalIndex).trim();
|
|
73
|
+
let value = trimmed.substring(equalIndex + 1).trim();
|
|
74
|
+
|
|
75
|
+
// 따옴표 제거
|
|
76
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
77
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
78
|
+
value = value.slice(1, -1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 이스케이프 문자 처리
|
|
82
|
+
value = value
|
|
83
|
+
.replace(/\\n/g, "\n")
|
|
84
|
+
.replace(/\\r/g, "\r")
|
|
85
|
+
.replace(/\\t/g, "\t");
|
|
86
|
+
|
|
87
|
+
result[key] = value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 파일이 존재하는지 확인
|
|
95
|
+
*/
|
|
96
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
97
|
+
try {
|
|
98
|
+
const file = Bun.file(filePath);
|
|
99
|
+
return await file.exists();
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 파일 내용 읽기
|
|
107
|
+
*/
|
|
108
|
+
async function readFile(filePath: string): Promise<string | null> {
|
|
109
|
+
try {
|
|
110
|
+
const file = Bun.file(filePath);
|
|
111
|
+
if (await file.exists()) {
|
|
112
|
+
return await file.text();
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ========== Main Functions ==========
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* .env 파일들 로드
|
|
124
|
+
*
|
|
125
|
+
* 로드 순서 (나중에 로드된 것이 우선):
|
|
126
|
+
* 1. .env (기본 설정)
|
|
127
|
+
* 2. .env.{environment} (환경별 설정)
|
|
128
|
+
* 3. .env.local (로컬 오버라이드, git에 포함하지 않음)
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```typescript
|
|
132
|
+
* await loadEnv(); // 기본 설정
|
|
133
|
+
*
|
|
134
|
+
* await loadEnv({
|
|
135
|
+
* env: 'production',
|
|
136
|
+
* required: ['DATABASE_URL', 'API_KEY'],
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export async function loadEnv(config: EnvConfig = {}): Promise<EnvValidationResult> {
|
|
141
|
+
const {
|
|
142
|
+
rootDir = process.cwd(),
|
|
143
|
+
env = process.env.NODE_ENV || "development",
|
|
144
|
+
files,
|
|
145
|
+
required = [],
|
|
146
|
+
defaults = {},
|
|
147
|
+
} = config;
|
|
148
|
+
|
|
149
|
+
const result: EnvValidationResult = {
|
|
150
|
+
success: true,
|
|
151
|
+
loaded: [],
|
|
152
|
+
missing: [],
|
|
153
|
+
errors: [],
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// 기본 파일 순서
|
|
157
|
+
const envFiles = files || [
|
|
158
|
+
".env",
|
|
159
|
+
`.env.${env}`,
|
|
160
|
+
".env.local",
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
// 기본값 먼저 적용
|
|
164
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
165
|
+
if (process.env[key] === undefined) {
|
|
166
|
+
process.env[key] = value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// .env 파일들 로드
|
|
171
|
+
for (const envFile of envFiles) {
|
|
172
|
+
const filePath = path.join(rootDir, envFile);
|
|
173
|
+
|
|
174
|
+
const content = await readFile(filePath);
|
|
175
|
+
if (content !== null) {
|
|
176
|
+
const parsed = parseEnvFile(content);
|
|
177
|
+
|
|
178
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
179
|
+
process.env[key] = value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
result.loaded.push(envFile);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 필수 환경 변수 검증
|
|
187
|
+
for (const key of required) {
|
|
188
|
+
if (!process.env[key]) {
|
|
189
|
+
result.missing.push(key);
|
|
190
|
+
result.errors.push(`Missing required environment variable: ${key}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (result.missing.length > 0) {
|
|
195
|
+
result.success = false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 환경 변수 타입 안전하게 가져오기
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```typescript
|
|
206
|
+
* const port = env('PORT', '3000'); // string
|
|
207
|
+
* const debug = env.bool('DEBUG', false); // boolean
|
|
208
|
+
* const timeout = env.number('TIMEOUT', 5000); // number
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
export function env(key: string, defaultValue?: string): string {
|
|
212
|
+
return process.env[key] ?? defaultValue ?? "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 환경 변수 헬퍼 함수들
|
|
217
|
+
*/
|
|
218
|
+
export const envHelpers = {
|
|
219
|
+
/**
|
|
220
|
+
* 문자열 환경 변수
|
|
221
|
+
*/
|
|
222
|
+
string(key: string, defaultValue: string = ""): string {
|
|
223
|
+
return process.env[key] ?? defaultValue;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 숫자 환경 변수
|
|
228
|
+
*/
|
|
229
|
+
number(key: string, defaultValue: number = 0): number {
|
|
230
|
+
const value = process.env[key];
|
|
231
|
+
if (value === undefined) return defaultValue;
|
|
232
|
+
const parsed = Number(value);
|
|
233
|
+
return isNaN(parsed) ? defaultValue : parsed;
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 불리언 환경 변수
|
|
238
|
+
*/
|
|
239
|
+
bool(key: string, defaultValue: boolean = false): boolean {
|
|
240
|
+
const value = process.env[key];
|
|
241
|
+
if (value === undefined) return defaultValue;
|
|
242
|
+
return value === "true" || value === "1" || value === "yes";
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 배열 환경 변수 (쉼표로 구분)
|
|
247
|
+
*/
|
|
248
|
+
array(key: string, defaultValue: string[] = []): string[] {
|
|
249
|
+
const value = process.env[key];
|
|
250
|
+
if (value === undefined) return defaultValue;
|
|
251
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* JSON 환경 변수
|
|
256
|
+
*/
|
|
257
|
+
json<T>(key: string, defaultValue: T): T {
|
|
258
|
+
const value = process.env[key];
|
|
259
|
+
if (value === undefined) return defaultValue;
|
|
260
|
+
try {
|
|
261
|
+
return JSON.parse(value) as T;
|
|
262
|
+
} catch {
|
|
263
|
+
return defaultValue;
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 필수 환경 변수 (없으면 에러)
|
|
269
|
+
*/
|
|
270
|
+
required(key: string): string {
|
|
271
|
+
const value = process.env[key];
|
|
272
|
+
if (value === undefined) {
|
|
273
|
+
throw new Error(`Missing required environment variable: ${key}`);
|
|
274
|
+
}
|
|
275
|
+
return value;
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 현재 환경 이름
|
|
280
|
+
*/
|
|
281
|
+
get NODE_ENV(): string {
|
|
282
|
+
return process.env.NODE_ENV || "development";
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 개발 환경 여부
|
|
287
|
+
*/
|
|
288
|
+
get isDevelopment(): boolean {
|
|
289
|
+
return this.NODE_ENV === "development";
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 프로덕션 환경 여부
|
|
294
|
+
*/
|
|
295
|
+
get isProduction(): boolean {
|
|
296
|
+
return this.NODE_ENV === "production";
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 테스트 환경 여부
|
|
301
|
+
*/
|
|
302
|
+
get isTest(): boolean {
|
|
303
|
+
return this.NODE_ENV === "test";
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 환경 변수 스키마 정의 및 검증
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* ```typescript
|
|
312
|
+
* const config = defineEnvSchema({
|
|
313
|
+
* DATABASE_URL: { type: 'string', required: true },
|
|
314
|
+
* PORT: { type: 'number', default: 3000 },
|
|
315
|
+
* DEBUG: { type: 'boolean', default: false },
|
|
316
|
+
* });
|
|
317
|
+
*
|
|
318
|
+
* // 자동으로 타입 추론됨
|
|
319
|
+
* config.DATABASE_URL // string
|
|
320
|
+
* config.PORT // number
|
|
321
|
+
* config.DEBUG // boolean
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
export interface EnvSchemaField {
|
|
325
|
+
type: "string" | "number" | "boolean" | "array" | "json";
|
|
326
|
+
required?: boolean;
|
|
327
|
+
default?: unknown;
|
|
328
|
+
description?: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export type EnvSchema = Record<string, EnvSchemaField>;
|
|
332
|
+
|
|
333
|
+
export type InferEnvSchema<T extends EnvSchema> = {
|
|
334
|
+
[K in keyof T]: T[K]["type"] extends "string"
|
|
335
|
+
? string
|
|
336
|
+
: T[K]["type"] extends "number"
|
|
337
|
+
? number
|
|
338
|
+
: T[K]["type"] extends "boolean"
|
|
339
|
+
? boolean
|
|
340
|
+
: T[K]["type"] extends "array"
|
|
341
|
+
? string[]
|
|
342
|
+
: unknown;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
export function defineEnvSchema<T extends EnvSchema>(
|
|
346
|
+
schema: T
|
|
347
|
+
): InferEnvSchema<T> {
|
|
348
|
+
const result: Record<string, unknown> = {};
|
|
349
|
+
|
|
350
|
+
for (const [key, field] of Object.entries(schema)) {
|
|
351
|
+
const { type, required = false, default: defaultValue } = field;
|
|
352
|
+
|
|
353
|
+
let value: unknown;
|
|
354
|
+
|
|
355
|
+
switch (type) {
|
|
356
|
+
case "string":
|
|
357
|
+
value = envHelpers.string(key, defaultValue as string);
|
|
358
|
+
break;
|
|
359
|
+
case "number":
|
|
360
|
+
value = envHelpers.number(key, defaultValue as number);
|
|
361
|
+
break;
|
|
362
|
+
case "boolean":
|
|
363
|
+
value = envHelpers.bool(key, defaultValue as boolean);
|
|
364
|
+
break;
|
|
365
|
+
case "array":
|
|
366
|
+
value = envHelpers.array(key, defaultValue as string[]);
|
|
367
|
+
break;
|
|
368
|
+
case "json":
|
|
369
|
+
value = envHelpers.json(key, defaultValue);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (required && (value === undefined || value === "")) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`Missing required environment variable: ${key}${field.description ? ` (${field.description})` : ""}`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
result[key] = value;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return result as InferEnvSchema<T>;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Re-export for convenience
|
|
386
|
+
export { env as getEnv };
|
package/src/runtime/index.ts
CHANGED
package/src/runtime/server.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import type { RoutesManifest } from "../spec/schema";
|
|
3
|
+
import type { BundleManifest } from "../bundler/types";
|
|
3
4
|
import { Router } from "./router";
|
|
4
5
|
import { renderSSR } from "./ssr";
|
|
5
6
|
import React from "react";
|
|
7
|
+
import path from "path";
|
|
6
8
|
import {
|
|
7
9
|
formatErrorResponse,
|
|
8
10
|
createNotFoundResponse,
|
|
@@ -10,14 +12,86 @@ import {
|
|
|
10
12
|
createPageLoadErrorResponse,
|
|
11
13
|
createSSRErrorResponse,
|
|
12
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
|
+
};
|
|
13
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 ==========
|
|
14
75
|
export interface ServerOptions {
|
|
15
76
|
port?: number;
|
|
16
77
|
hostname?: string;
|
|
78
|
+
/** 프로젝트 루트 디렉토리 */
|
|
79
|
+
rootDir?: string;
|
|
17
80
|
/** 개발 모드 여부 */
|
|
18
81
|
isDev?: boolean;
|
|
19
82
|
/** HMR 포트 (개발 모드에서 사용) */
|
|
20
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;
|
|
21
95
|
}
|
|
22
96
|
|
|
23
97
|
export interface ManduServer {
|
|
@@ -44,8 +118,20 @@ const pageLoaders: Map<string, PageLoader> = new Map();
|
|
|
44
118
|
const routeComponents: Map<string, RouteComponent> = new Map();
|
|
45
119
|
let createAppFn: CreateAppFn | null = null;
|
|
46
120
|
|
|
47
|
-
//
|
|
48
|
-
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
|
+
};
|
|
49
135
|
|
|
50
136
|
export function registerApiHandler(routeId: string, handler: ApiHandler): void {
|
|
51
137
|
apiHandlers.set(routeId, handler);
|
|
@@ -77,10 +163,99 @@ function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
|
77
163
|
return React.createElement(Component, { params: context.params });
|
|
78
164
|
}
|
|
79
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
|
+
|
|
80
237
|
async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
81
238
|
const url = new URL(req.url);
|
|
82
239
|
const pathname = url.pathname;
|
|
83
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. 라우트 매칭
|
|
84
259
|
const match = router.match(pathname);
|
|
85
260
|
|
|
86
261
|
if (!match) {
|
|
@@ -135,8 +310,11 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
135
310
|
|
|
136
311
|
return renderSSR(app, {
|
|
137
312
|
title: `${route.id} - Mandu`,
|
|
138
|
-
isDev:
|
|
139
|
-
hmrPort:
|
|
313
|
+
isDev: serverSettings.isDev,
|
|
314
|
+
hmrPort: serverSettings.hmrPort,
|
|
315
|
+
routeId: route.id,
|
|
316
|
+
hydration: route.hydration,
|
|
317
|
+
bundleManifest: serverSettings.bundleManifest,
|
|
140
318
|
});
|
|
141
319
|
} catch (err) {
|
|
142
320
|
const ssrError = createSSRErrorResponse(
|
|
@@ -169,18 +347,51 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
169
347
|
}, { status: 500 });
|
|
170
348
|
}
|
|
171
349
|
|
|
350
|
+
// ========== Server Startup ==========
|
|
351
|
+
|
|
172
352
|
export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
|
|
173
|
-
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;
|
|
174
366
|
|
|
175
|
-
//
|
|
176
|
-
|
|
367
|
+
// Server settings 저장
|
|
368
|
+
serverSettings = {
|
|
369
|
+
isDev,
|
|
370
|
+
hmrPort,
|
|
371
|
+
bundleManifest,
|
|
372
|
+
rootDir,
|
|
373
|
+
publicDir,
|
|
374
|
+
cors: corsOptions,
|
|
375
|
+
};
|
|
177
376
|
|
|
178
377
|
const router = new Router(manifest.routes);
|
|
179
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
|
+
|
|
180
391
|
const server = Bun.serve({
|
|
181
392
|
port,
|
|
182
393
|
hostname,
|
|
183
|
-
fetch:
|
|
394
|
+
fetch: fetchHandler,
|
|
184
395
|
});
|
|
185
396
|
|
|
186
397
|
if (isDev) {
|
|
@@ -188,6 +399,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
188
399
|
if (hmrPort) {
|
|
189
400
|
console.log(`🔥 HMR enabled on port ${hmrPort + 1}`);
|
|
190
401
|
}
|
|
402
|
+
console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
|
|
403
|
+
if (corsOptions) {
|
|
404
|
+
console.log(`🌐 CORS enabled`);
|
|
405
|
+
}
|
|
191
406
|
} else {
|
|
192
407
|
console.log(`🥟 Mandu server running at http://${hostname}:${port}`);
|
|
193
408
|
}
|