@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.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# @zipbul/cors
|
|
2
|
+
|
|
3
|
+
**English** | [ํ๊ตญ์ด](./README.ko.md)
|
|
4
|
+
|
|
5
|
+
A framework-agnostic CORS handling library.
|
|
6
|
+
Instead of generating responses directly, it returns a **discriminated union** result, giving the caller full control over the response.
|
|
7
|
+
|
|
8
|
+
> Uses standard Web APIs (`Request` / `Response`).
|
|
9
|
+
|
|
10
|
+
<br>
|
|
11
|
+
|
|
12
|
+
## ๐ฆ Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add @zipbul/cors
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
<br>
|
|
19
|
+
|
|
20
|
+
## ๐ก Core Concept
|
|
21
|
+
|
|
22
|
+
`handle()` does not create a response. It only tells you **what to do next**.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
CorsResult
|
|
26
|
+
โโโ Continue โ Attach CORS headers to the response and continue
|
|
27
|
+
โโโ RespondPreflight โ Return a preflight-only response immediately
|
|
28
|
+
โโโ Reject โ Reject the request (with reason)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This design fits naturally into any environment โ middleware pipelines, edge runtimes, custom error formats, and more.
|
|
32
|
+
|
|
33
|
+
<br>
|
|
34
|
+
|
|
35
|
+
## ๐ Quick Start
|
|
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 config error: ${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 โ merge CORS headers into your response
|
|
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
|
+
## โ๏ธ Options
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
interface CorsOptions {
|
|
89
|
+
origin?: OriginOptions; // Default: '*'
|
|
90
|
+
methods?: CorsMethod[]; // Default: GET, HEAD, PUT, PATCH, POST, DELETE
|
|
91
|
+
allowedHeaders?: string[]; // Default: reflects request's ACRH
|
|
92
|
+
exposedHeaders?: string[]; // Default: none
|
|
93
|
+
credentials?: boolean; // Default: false
|
|
94
|
+
maxAge?: number; // Default: none (header not included)
|
|
95
|
+
preflightContinue?: boolean; // Default: false
|
|
96
|
+
optionsSuccessStatus?: number; // Default: 204
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### `origin`
|
|
101
|
+
|
|
102
|
+
| Value | Behavior |
|
|
103
|
+
|:------|:---------|
|
|
104
|
+
| `'*'` _(default)_ | Allow all origins |
|
|
105
|
+
| `false` | Reject all origins |
|
|
106
|
+
| `true` | Reflect the request origin |
|
|
107
|
+
| `'https://example.com'` | Allow only the exact match |
|
|
108
|
+
| `/^https:\/\/(.+\.)?example\.com$/` | Regex matching |
|
|
109
|
+
| `['https://a.com', /^https:\/\/b\./]` | Array (mix of strings and regexes) |
|
|
110
|
+
| `(origin, request) => boolean \| string` | Function (sync or async) |
|
|
111
|
+
|
|
112
|
+
> When `credentials: true`, `origin: '*'` causes a **validation error**. Use `origin: true` to reflect the request origin.
|
|
113
|
+
>
|
|
114
|
+
> RegExp origins are checked for **ReDoS safety** at creation time using [safe-regex2](https://github.com/fastify/safe-regex2). Patterns with star height โฅ 2 (e.g. `/(a+)+$/`) are rejected with `CorsErrorReason.UnsafeRegExp`.
|
|
115
|
+
|
|
116
|
+
### `methods`
|
|
117
|
+
|
|
118
|
+
HTTP methods to allow in preflight. Accepts `CorsMethod[]` โ standard methods are autocompleted, and any RFC 9110 ยง5.6.2 token (e.g. `'PROPFIND'`) is also valid.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
Cors.create({ methods: ['GET', 'POST', 'DELETE'] });
|
|
122
|
+
Cors.create({ methods: ['GET', 'PROPFIND'] }); // custom token
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A wildcard `'*'` allows all methods. With `credentials: true`, the wildcard is replaced by echoing the request method.
|
|
126
|
+
|
|
127
|
+
### `allowedHeaders`
|
|
128
|
+
|
|
129
|
+
Request headers to allow in preflight. When not set, the client's `Access-Control-Request-Headers` value is echoed back.
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
Cors.create({ allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'] });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
> **โ ๏ธ Authorization caveat** โ Per the Fetch Standard, a wildcard `'*'` alone does not cover the `Authorization` header. You must list it explicitly.
|
|
136
|
+
>
|
|
137
|
+
> ```typescript
|
|
138
|
+
> Cors.create({ allowedHeaders: ['*', 'Authorization'] });
|
|
139
|
+
> ```
|
|
140
|
+
|
|
141
|
+
### `exposedHeaders`
|
|
142
|
+
|
|
143
|
+
Response headers to expose to browser JavaScript.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
Cors.create({ exposedHeaders: ['X-Request-Id', 'X-Rate-Limit-Remaining'] });
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
> With `credentials: true`, using a wildcard `'*'` causes the `Access-Control-Expose-Headers` header to not be set at all.
|
|
150
|
+
|
|
151
|
+
### `credentials`
|
|
152
|
+
|
|
153
|
+
Whether to include the `Access-Control-Allow-Credentials: true` header.
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
Cors.create({ origin: 'https://app.example.com', credentials: true });
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `maxAge`
|
|
160
|
+
|
|
161
|
+
How long (in seconds) the browser may cache the preflight result.
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
Cors.create({ maxAge: 86400 }); // 24 hours
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### `preflightContinue`
|
|
168
|
+
|
|
169
|
+
When set to `true`, preflight requests are not handled automatically. Instead, `CorsAction.Continue` is returned, delegating to the next handler.
|
|
170
|
+
|
|
171
|
+
### `optionsSuccessStatus`
|
|
172
|
+
|
|
173
|
+
HTTP status code for the preflight response. Defaults to `204`. Set to `200` if legacy browser compatibility is needed.
|
|
174
|
+
|
|
175
|
+
<br>
|
|
176
|
+
|
|
177
|
+
## ๐ค Return Types
|
|
178
|
+
|
|
179
|
+
`handle()` returns `Promise<CorsResult>`. `CorsResult` is a discriminated union of three interfaces.
|
|
180
|
+
|
|
181
|
+
#### `CorsContinueResult`
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
{ action: CorsAction.Continue; headers: Headers }
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Returned for normal (non-OPTIONS) requests, or preflight when `preflightContinue: true`. Merge `headers` into your response directly.
|
|
188
|
+
|
|
189
|
+
#### `CorsPreflightResult`
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
{ action: CorsAction.RespondPreflight; headers: Headers; statusCode: number }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Returned for `OPTIONS` requests that include `Access-Control-Request-Method`. Use `headers` and `statusCode` to build a response.
|
|
196
|
+
|
|
197
|
+
#### `CorsRejectResult`
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
{ action: CorsAction.Reject; reason: CorsRejectionReason }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Returned when CORS validation fails. Use `reason` to build a detailed error response.
|
|
204
|
+
|
|
205
|
+
| `CorsRejectionReason` | Meaning |
|
|
206
|
+
|:-----------------------|:--------|
|
|
207
|
+
| `NoOrigin` | `Origin` header missing or empty |
|
|
208
|
+
| `OriginNotAllowed` | Origin not in the allowed list |
|
|
209
|
+
| `MethodNotAllowed` | Request method not in the allowed list |
|
|
210
|
+
| `HeaderNotAllowed` | Request header not in the allowed list |
|
|
211
|
+
|
|
212
|
+
`Cors.create()` returns `Err<CorsError>` when options fail validation:
|
|
213
|
+
|
|
214
|
+
| `CorsErrorReason` | Meaning |
|
|
215
|
+
|:------------------|:--------|
|
|
216
|
+
| `CredentialsWithWildcardOrigin` | `credentials:true` with `origin:'*'` (Fetch Standard ยง3.3.5) |
|
|
217
|
+
| `InvalidMaxAge` | `maxAge` is not a non-negative integer (RFC 9111 ยง1.2.1) |
|
|
218
|
+
| `InvalidStatusCode` | `optionsSuccessStatus` is not a 2xx integer |
|
|
219
|
+
| `InvalidOrigin` | `origin` is an empty/blank string, empty array, or array with empty/blank entries (RFC 6454) |
|
|
220
|
+
| `InvalidMethods` | `methods` is empty, or contains empty/blank entries (RFC 9110 ยง5.6.2) |
|
|
221
|
+
| `InvalidAllowedHeaders` | `allowedHeaders` contains empty/blank entries (RFC 9110 ยง5.6.2) |
|
|
222
|
+
| `InvalidExposedHeaders` | `exposedHeaders` contains empty/blank entries (RFC 9110 ยง5.6.2) |
|
|
223
|
+
| `OriginFunctionError` | Origin function threw at runtime |
|
|
224
|
+
| `UnsafeRegExp` | origin RegExp has exponential backtracking risk (ReDoS) |
|
|
225
|
+
|
|
226
|
+
<br>
|
|
227
|
+
|
|
228
|
+
## ๐ฌ Advanced Usage
|
|
229
|
+
|
|
230
|
+
### Origin option patterns
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// Single origin
|
|
234
|
+
Cors.create({ origin: 'https://app.example.com' });
|
|
235
|
+
|
|
236
|
+
// Multiple origins (mix of strings and regexes)
|
|
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
|
+
// Regex to allow all subdomains
|
|
246
|
+
Cors.create({ origin: /^https:\/\/(.+\.)?example\.com$/ });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Async origin function
|
|
250
|
+
|
|
251
|
+
Dynamically validate origins via a database or external service.
|
|
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 โ reflect the request origin
|
|
261
|
+
// string โ use the specified string
|
|
262
|
+
// false โ reject
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
> If the origin function throws, `handle()` returns `Err<CorsError>` with `reason: CorsErrorReason.OriginFunctionError`. The error is wrapped, not re-thrown.
|
|
268
|
+
|
|
269
|
+
### Wildcards and credentials
|
|
270
|
+
|
|
271
|
+
Per the Fetch Standard, wildcards (`*`) cannot be used with credentialed requests (cookies, `Authorization`).
|
|
272
|
+
When `credentials: true`, the library automatically handles the following:
|
|
273
|
+
|
|
274
|
+
| Option | Behavior with wildcard |
|
|
275
|
+
|:-------|:-----------------------|
|
|
276
|
+
| `origin: '*'` | **Validation error** โ use `origin: true` to reflect the request origin |
|
|
277
|
+
| `methods: ['*']` | Echoes the request method |
|
|
278
|
+
| `allowedHeaders: ['*']` | Echoes the request headers |
|
|
279
|
+
| `exposedHeaders: ['*']` | `Access-Control-Expose-Headers` is not set |
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// โ
origin: true + credentials: true โ request origin is reflected
|
|
283
|
+
Cors.create({ origin: true, credentials: true });
|
|
284
|
+
|
|
285
|
+
// โ
Specific domain + credentials
|
|
286
|
+
Cors.create({ origin: 'https://app.example.com', credentials: true });
|
|
287
|
+
|
|
288
|
+
// โ origin: '*' + credentials: true โ Cors.create() returns Err<CorsError>
|
|
289
|
+
Cors.create({ origin: '*', credentials: true }); // CorsErrorReason.CredentialsWithWildcardOrigin
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Preflight delegation
|
|
293
|
+
|
|
294
|
+
When another middleware needs to handle OPTIONS requests directly:
|
|
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 โ both normal and preflight requests arrive here
|
|
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
|
+
## ๐ Framework Integration Examples
|
|
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>Middleware pattern</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
|
+
## ๐ License
|
|
427
|
+
|
|
428
|
+
MIT
|