@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.
@@ -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";