@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.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
@@ -0,0 +1,5 @@
1
+ export * from './src/cors';
2
+ export * from './src/enums';
3
+ export * from './src/interfaces';
4
+ export * from './src/options';
5
+ export * from './src/types';