@mandujs/core 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bundler/build.ts +114 -57
- package/src/bundler/types.ts +5 -1
- package/src/client/index.ts +9 -1
- package/src/client/island.ts +59 -0
- package/src/filling/context.ts +235 -5
- package/src/filling/index.ts +2 -1
- package/src/runtime/cors.ts +277 -0
- package/src/runtime/env.ts +386 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/server.ts +218 -9
- package/src/runtime/ssr.ts +18 -5
|
@@ -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 };
|