@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- return Response.json(data, { status });
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
- return new Response(data, {
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
- return new Response(data, {
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
- return Response.redirect(url, status);
390
+ const response = Response.redirect(url, status);
391
+ return this.withCookies(response);
162
392
  }
163
393
 
164
394
  // ============================================
@@ -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 };
@@ -1,3 +1,5 @@
1
1
  export * from "./ssr";
2
2
  export * from "./router";
3
3
  export * from "./server";
4
+ export * from "./cors";
5
+ export * from "./env";
@@ -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
- // Dev mode settings (module-level for handleRequest access)
48
- let devModeSettings: { isDev: boolean; hmrPort?: number } = { 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
+ };
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: devModeSettings.isDev,
139
- hmrPort: devModeSettings.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 { port = 3000, hostname = "localhost", isDev = false, hmrPort } = 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;
174
366
 
175
- // Dev mode settings 저장
176
- devModeSettings = { isDev, hmrPort };
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: (req) => handleRequest(req, router),
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
  }