@zipbul/cors 0.0.1
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/README.ko.md +428 -0
- package/README.md +428 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +359 -0
- package/dist/index.js.map +13 -0
- package/dist/src/constants.d.ts +3 -0
- package/dist/src/cors.d.ts +38 -0
- package/dist/src/enums.d.ts +48 -0
- package/dist/src/interfaces.d.ts +90 -0
- package/dist/src/options.d.ts +25 -0
- package/dist/src/types.d.ts +39 -0
- package/package.json +39 -0
package/README.ko.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# @zipbul/cors
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | **한국어**
|
|
4
|
+
|
|
5
|
+
프레임워크에 종속되지 않는 CORS 처리 라이브러리.
|
|
6
|
+
응답을 직접 생성하지 않고, **판별 유니온(discriminated union)** 결과를 반환하여 호출자가 응답 방식을 완전히 제어할 수 있도록 설계되었습니다.
|
|
7
|
+
|
|
8
|
+
> 표준 Web API(`Request` / `Response`)를 사용합니다.
|
|
9
|
+
|
|
10
|
+
<br>
|
|
11
|
+
|
|
12
|
+
## 📦 설치
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add @zipbul/cors
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
<br>
|
|
19
|
+
|
|
20
|
+
## 💡 핵심 개념
|
|
21
|
+
|
|
22
|
+
`handle()` 은 응답을 만들지 않습니다. **다음에 무엇을 해야 하는지**만 알려줍니다.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
CorsResult
|
|
26
|
+
├── Continue → CORS 헤더를 응답에 추가한 뒤 계속 처리
|
|
27
|
+
├── RespondPreflight → 프리플라이트 전용 응답을 즉시 반환
|
|
28
|
+
└── Reject → 거부 (사유 포함)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
이 구조 덕분에 미들웨어 파이프라인, 엣지 런타임, 커스텀 에러 포맷 등 어떤 환경에도 자연스럽게 맞춰집니다.
|
|
32
|
+
|
|
33
|
+
<br>
|
|
34
|
+
|
|
35
|
+
## 🚀 빠른 시작
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { Cors, CorsAction } from '@zipbul/cors';
|
|
39
|
+
import { isErr } from '@zipbul/result';
|
|
40
|
+
|
|
41
|
+
const corsResult = Cors.create({
|
|
42
|
+
origin: 'https://my-app.example.com',
|
|
43
|
+
credentials: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (isErr(corsResult)) {
|
|
47
|
+
throw new Error(`CORS 설정 오류: ${corsResult.data.message}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cors = corsResult;
|
|
51
|
+
|
|
52
|
+
async function handleRequest(request: Request): Promise<Response> {
|
|
53
|
+
const result = await cors.handle(request);
|
|
54
|
+
|
|
55
|
+
if (isErr(result)) {
|
|
56
|
+
return new Response('Internal Error', { status: 500 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (result.action === CorsAction.Reject) {
|
|
60
|
+
return new Response('Forbidden', { status: 403 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (result.action === CorsAction.RespondPreflight) {
|
|
64
|
+
return new Response(null, {
|
|
65
|
+
status: result.statusCode,
|
|
66
|
+
headers: result.headers,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// CorsAction.Continue — CORS 헤더를 응답에 병합
|
|
71
|
+
const response = new Response(JSON.stringify({ ok: true }), {
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
for (const [key, value] of result.headers) {
|
|
76
|
+
response.headers.set(key, value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return response;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
<br>
|
|
84
|
+
|
|
85
|
+
## ⚙️ 옵션
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
interface CorsOptions {
|
|
89
|
+
origin?: OriginOptions; // 기본값: '*'
|
|
90
|
+
methods?: CorsMethod[]; // 기본값: GET, HEAD, PUT, PATCH, POST, DELETE
|
|
91
|
+
allowedHeaders?: string[]; // 기본값: 요청의 ACRH 반영
|
|
92
|
+
exposedHeaders?: string[]; // 기본값: 없음
|
|
93
|
+
credentials?: boolean; // 기본값: false
|
|
94
|
+
maxAge?: number; // 기본값: 없음 (헤더 미포함)
|
|
95
|
+
preflightContinue?: boolean; // 기본값: false
|
|
96
|
+
optionsSuccessStatus?: number; // 기본값: 204
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### `origin`
|
|
101
|
+
|
|
102
|
+
| 값 | 동작 |
|
|
103
|
+
|:---|:---|
|
|
104
|
+
| `'*'` _(기본)_ | 모든 출처 허용 |
|
|
105
|
+
| `false` | 모든 출처 거부 |
|
|
106
|
+
| `true` | 요청 출처를 그대로 반영 |
|
|
107
|
+
| `'https://example.com'` | 정확히 일치하는 출처만 허용 |
|
|
108
|
+
| `/^https:\/\/(.+\.)?example\.com$/` | 정규식 매칭 |
|
|
109
|
+
| `['https://a.com', /^https:\/\/b\./]` | 배열 (문자열·정규식 혼합) |
|
|
110
|
+
| `(origin, request) => boolean \| string` | 함수 (동기·비동기) |
|
|
111
|
+
|
|
112
|
+
> `credentials: true`일 때 `origin: '*'`는 **검증 오류**를 발생시킵니다. 요청 출처를 반영하려면 `origin: true`를 사용하세요.
|
|
113
|
+
>
|
|
114
|
+
> RegExp origin은 생성 시점에 [safe-regex2](https://github.com/fastify/safe-regex2)를 사용하여 **ReDoS 안전성**을 검사합니다. star height ≥ 2인 패턴(예: `/(a+)+$/`)은 `CorsErrorReason.UnsafeRegExp`으로 거부됩니다.
|
|
115
|
+
|
|
116
|
+
### `methods`
|
|
117
|
+
|
|
118
|
+
프리플라이트에서 허용할 HTTP 메서드 목록. `CorsMethod[]`를 받으며, 표준 메서드는 자동 완성되고 RFC 9110 §5.6.2 토큰(예: `'PROPFIND'`)도 허용합니다.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
Cors.create({ methods: ['GET', 'POST', 'DELETE'] });
|
|
122
|
+
Cors.create({ methods: ['GET', 'PROPFIND'] }); // 커스텀 토큰
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
와일드카드 `'*'`를 넣으면 모든 메서드를 허용합니다. `credentials: true`이면 와일드카드 대신 요청 메서드를 그대로 반영합니다.
|
|
126
|
+
|
|
127
|
+
### `allowedHeaders`
|
|
128
|
+
|
|
129
|
+
프리플라이트에서 허용할 요청 헤더 목록. 미설정 시 클라이언트의 `Access-Control-Request-Headers` 값을 그대로 반영합니다.
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
Cors.create({ allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'] });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
> **⚠️ Authorization 주의** — Fetch Standard에 따라, 와일드카드 `'*'`만으로는 `Authorization` 헤더가 허용되지 않습니다. 반드시 명시적으로 추가해야 합니다.
|
|
136
|
+
>
|
|
137
|
+
> ```typescript
|
|
138
|
+
> Cors.create({ allowedHeaders: ['*', 'Authorization'] });
|
|
139
|
+
> ```
|
|
140
|
+
|
|
141
|
+
### `exposedHeaders`
|
|
142
|
+
|
|
143
|
+
브라우저 JavaScript에서 접근 가능하게 노출할 응답 헤더 목록.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
Cors.create({ exposedHeaders: ['X-Request-Id', 'X-Rate-Limit-Remaining'] });
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
> `credentials: true` 환경에서 와일드카드 `'*'`를 사용하면 `Access-Control-Expose-Headers` 헤더 자체가 설정되지 않습니다.
|
|
150
|
+
|
|
151
|
+
### `credentials`
|
|
152
|
+
|
|
153
|
+
`Access-Control-Allow-Credentials: true` 헤더 포함 여부.
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
Cors.create({ origin: 'https://app.example.com', credentials: true });
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `maxAge`
|
|
160
|
+
|
|
161
|
+
프리플라이트 결과를 브라우저가 캐시할 시간(초).
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
Cors.create({ maxAge: 86400 }); // 24시간
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### `preflightContinue`
|
|
168
|
+
|
|
169
|
+
`true`로 설정하면 프리플라이트를 자동 처리하지 않고, `CorsAction.Continue`를 반환하여 다음 핸들러에게 위임합니다.
|
|
170
|
+
|
|
171
|
+
### `optionsSuccessStatus`
|
|
172
|
+
|
|
173
|
+
프리플라이트 응답의 HTTP 상태 코드. 기본값 `204`. 일부 레거시 브라우저 호환이 필요하면 `200`으로 설정합니다.
|
|
174
|
+
|
|
175
|
+
<br>
|
|
176
|
+
|
|
177
|
+
## 📤 반환 타입
|
|
178
|
+
|
|
179
|
+
`handle()`은 `Promise<CorsResult>`를 반환합니다. `CorsResult`는 세 가지 인터페이스의 판별 유니온입니다.
|
|
180
|
+
|
|
181
|
+
#### `CorsContinueResult`
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
{ action: CorsAction.Continue; headers: Headers }
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
일반 요청(비-OPTIONS) 또는 `preflightContinue: true`인 프리플라이트에서 반환됩니다. `headers`를 응답에 직접 병합하세요.
|
|
188
|
+
|
|
189
|
+
#### `CorsPreflightResult`
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
{ action: CorsAction.RespondPreflight; headers: Headers; statusCode: number }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`OPTIONS` + `Access-Control-Request-Method`가 포함된 프리플라이트에서 반환됩니다. `headers`와 `statusCode`를 사용하여 응답을 직접 구성합니다.
|
|
196
|
+
|
|
197
|
+
#### `CorsRejectResult`
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
{ action: CorsAction.Reject; reason: CorsRejectionReason }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
CORS 검증 실패 시 반환됩니다. `reason`으로 상세한 에러 응답을 구성할 수 있습니다.
|
|
204
|
+
|
|
205
|
+
| `CorsRejectionReason` | 의미 |
|
|
206
|
+
|:---|:---|
|
|
207
|
+
| `NoOrigin` | `Origin` 헤더 없음 또는 빈 문자열 |
|
|
208
|
+
| `OriginNotAllowed` | 출처가 허용 목록에 없음 |
|
|
209
|
+
| `MethodNotAllowed` | 요청 메서드가 허용 목록에 없음 |
|
|
210
|
+
| `HeaderNotAllowed` | 요청 헤더가 허용 목록에 없음 |
|
|
211
|
+
|
|
212
|
+
`Cors.create()`는 옵션 검증 실패 시 `Err<CorsError>`를 반환합니다:
|
|
213
|
+
|
|
214
|
+
| `CorsErrorReason` | 의미 |
|
|
215
|
+
|:------------------|:--------|
|
|
216
|
+
| `CredentialsWithWildcardOrigin` | `credentials:true` + `origin:'*'` 조합 불가 (Fetch Standard §3.3.5) |
|
|
217
|
+
| `InvalidMaxAge` | `maxAge`가 음수가 아닌 정수가 아님 (RFC 9111 §1.2.1) |
|
|
218
|
+
| `InvalidStatusCode` | `optionsSuccessStatus`가 2xx 정수가 아님 |
|
|
219
|
+
| `InvalidOrigin` | `origin`이 빈/공백 문자열, 빈 배열, 또는 배열 내 빈/공백 요소 (RFC 6454) |
|
|
220
|
+
| `InvalidMethods` | `methods`가 빈 배열이거나 빈/공백 요소 포함 (RFC 9110 §5.6.2) |
|
|
221
|
+
| `InvalidAllowedHeaders` | `allowedHeaders`에 빈/공백 요소 포함 (RFC 9110 §5.6.2) |
|
|
222
|
+
| `InvalidExposedHeaders` | `exposedHeaders`에 빈/공백 요소 포함 (RFC 9110 §5.6.2) |
|
|
223
|
+
| `OriginFunctionError` | 런타임에 origin 함수가 예외를 오발 |
|
|
224
|
+
| `UnsafeRegExp` | origin RegExp이 지수적 역추적 위험(ReDoS)을 가짐 |
|
|
225
|
+
|
|
226
|
+
<br>
|
|
227
|
+
|
|
228
|
+
## 🔬 고급 사용법
|
|
229
|
+
|
|
230
|
+
### origin 옵션 패턴
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// 단일 출처
|
|
234
|
+
Cors.create({ origin: 'https://app.example.com' });
|
|
235
|
+
|
|
236
|
+
// 여러 출처 (문자열 + 정규식 혼합)
|
|
237
|
+
Cors.create({
|
|
238
|
+
origin: [
|
|
239
|
+
'https://app.example.com',
|
|
240
|
+
'https://admin.example.com',
|
|
241
|
+
/^https:\/\/preview-\d+\.example\.com$/,
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// 정규식으로 서브도메인 전체 허용
|
|
246
|
+
Cors.create({ origin: /^https:\/\/(.+\.)?example\.com$/ });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### 비동기 origin 함수
|
|
250
|
+
|
|
251
|
+
데이터베이스나 외부 서비스를 통해 동적으로 출처를 검증할 수 있습니다.
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
Cors.create({
|
|
255
|
+
origin: async (origin, request) => {
|
|
256
|
+
const tenant = request.headers.get('X-Tenant-Id');
|
|
257
|
+
const allowed = await db.isOriginAllowed(tenant, origin);
|
|
258
|
+
|
|
259
|
+
return allowed ? true : false;
|
|
260
|
+
// true → 요청 origin 그대로 반영
|
|
261
|
+
// string → 지정한 문자열로 반영
|
|
262
|
+
// false → 거부
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
> origin 함수에서 예외가 발생하면 `handle()`은 `Err<CorsError>`를 `reason: CorsErrorReason.OriginFunctionError`와 함께 반환합니다. 에러는 래핑되며 다시 throw되지 않습니다.
|
|
268
|
+
|
|
269
|
+
### 와일드카드와 credentials
|
|
270
|
+
|
|
271
|
+
Fetch Standard에 따라 인증 요청(쿠키·`Authorization`)에는 와일드카드(`*`)를 사용할 수 없습니다.
|
|
272
|
+
`credentials: true`일 때 라이브러리가 자동으로 처리하는 항목은 다음과 같습니다.
|
|
273
|
+
|
|
274
|
+
| 옵션 | 와일드카드 시 동작 |
|
|
275
|
+
|:---|:---|
|
|
276
|
+
| `origin: '*'` | **검증 오류** — `origin: true`를 사용하여 요청 출처를 반영하세요 |
|
|
277
|
+
| `methods: ['*']` | 요청 메서드를 그대로 반영 |
|
|
278
|
+
| `allowedHeaders: ['*']` | 요청 헤더를 그대로 반영 |
|
|
279
|
+
| `exposedHeaders: ['*']` | `Access-Control-Expose-Headers` 미설정 |
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// ✅ origin: true + credentials: true → 요청 origin 자동 반영
|
|
283
|
+
Cors.create({ origin: true, credentials: true });
|
|
284
|
+
|
|
285
|
+
// ✅ 특정 도메인 + credentials
|
|
286
|
+
Cors.create({ origin: 'https://app.example.com', credentials: true });
|
|
287
|
+
|
|
288
|
+
// ❌ origin: '*' + credentials: true → Cors.create()가 Err<CorsError> 반환
|
|
289
|
+
Cors.create({ origin: '*', credentials: true }); // CorsErrorReason.CredentialsWithWildcardOrigin
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### 프리플라이트 위임
|
|
293
|
+
|
|
294
|
+
다른 미들웨어가 OPTIONS 요청을 직접 처리해야 하는 경우:
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
const cors = Cors.create({ preflightContinue: true }) as Cors;
|
|
298
|
+
|
|
299
|
+
async function handle(request: Request): Promise<Response> {
|
|
300
|
+
const result = await cors.handle(request);
|
|
301
|
+
|
|
302
|
+
if (isErr(result)) {
|
|
303
|
+
return new Response('Internal Error', { status: 500 });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (result.action === CorsAction.Reject) {
|
|
307
|
+
return new Response('Forbidden', { status: 403 });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Continue — 일반 요청과 프리플라이트 모두 여기로 진입
|
|
311
|
+
const response = await nextHandler(request);
|
|
312
|
+
|
|
313
|
+
for (const [key, value] of result.headers) {
|
|
314
|
+
response.headers.set(key, value);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return response;
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
<br>
|
|
322
|
+
|
|
323
|
+
## 🔌 프레임워크 통합 예시
|
|
324
|
+
|
|
325
|
+
<details>
|
|
326
|
+
<summary><b>Bun.serve</b></summary>
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
import { Cors, CorsAction } from '@zipbul/cors';
|
|
330
|
+
import { isErr } from '@zipbul/result';
|
|
331
|
+
|
|
332
|
+
const corsResult = Cors.create({
|
|
333
|
+
origin: ['https://app.example.com'],
|
|
334
|
+
credentials: true,
|
|
335
|
+
exposedHeaders: ['X-Request-Id'],
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (isErr(corsResult)) throw new Error(corsResult.data.message);
|
|
339
|
+
const cors = corsResult;
|
|
340
|
+
|
|
341
|
+
Bun.serve({
|
|
342
|
+
async fetch(request) {
|
|
343
|
+
const result = await cors.handle(request);
|
|
344
|
+
|
|
345
|
+
if (isErr(result)) {
|
|
346
|
+
return new Response('Internal Error', { status: 500 });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (result.action === CorsAction.Reject) {
|
|
350
|
+
return new Response(
|
|
351
|
+
JSON.stringify({ error: 'CORS policy violation', reason: result.reason }),
|
|
352
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (result.action === CorsAction.RespondPreflight) {
|
|
357
|
+
return new Response(null, {
|
|
358
|
+
status: result.statusCode,
|
|
359
|
+
headers: result.headers,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const response = await router.handle(request);
|
|
364
|
+
|
|
365
|
+
for (const [key, value] of result.headers) {
|
|
366
|
+
response.headers.set(key, value);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return response;
|
|
370
|
+
},
|
|
371
|
+
port: 3000,
|
|
372
|
+
});
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
</details>
|
|
376
|
+
|
|
377
|
+
<details>
|
|
378
|
+
<summary><b>미들웨어 패턴</b></summary>
|
|
379
|
+
|
|
380
|
+
```typescript
|
|
381
|
+
import { Cors, CorsAction } from '@zipbul/cors';
|
|
382
|
+
import type { CorsOptions } from '@zipbul/cors';
|
|
383
|
+
import { isErr } from '@zipbul/result';
|
|
384
|
+
|
|
385
|
+
function corsMiddleware(options?: CorsOptions) {
|
|
386
|
+
const createResult = Cors.create(options);
|
|
387
|
+
if (isErr(createResult)) throw new Error(createResult.data.message);
|
|
388
|
+
const cors = createResult;
|
|
389
|
+
|
|
390
|
+
return async (ctx: Context, next: () => Promise<void>) => {
|
|
391
|
+
const result = await cors.handle(ctx.request);
|
|
392
|
+
|
|
393
|
+
if (isErr(result)) {
|
|
394
|
+
ctx.status = 500;
|
|
395
|
+
ctx.body = { error: 'CORS_INTERNAL_ERROR' };
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (result.action === CorsAction.Reject) {
|
|
400
|
+
ctx.status = 403;
|
|
401
|
+
ctx.body = { error: 'CORS_VIOLATION', reason: result.reason };
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (result.action === CorsAction.RespondPreflight) {
|
|
406
|
+
ctx.response = new Response(null, {
|
|
407
|
+
status: result.statusCode,
|
|
408
|
+
headers: result.headers,
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
await next();
|
|
414
|
+
|
|
415
|
+
for (const [key, value] of result.headers) {
|
|
416
|
+
ctx.response.headers.set(key, value);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
</details>
|
|
423
|
+
|
|
424
|
+
<br>
|
|
425
|
+
|
|
426
|
+
## 📄 라이선스
|
|
427
|
+
|
|
428
|
+
MIT
|