@remix-run/cors-middleware 0.0.0 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Shopify Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,174 @@
1
- # Placeholder Package
1
+ # cors-middleware
2
2
 
3
- This package is a placeholder published at `0.0.0` to reserve the npm name and configure CI publish permissions.
3
+ CORS middleware for Remix. It adds standard CORS response headers to Fetch API servers and can either short-circuit preflight requests or pass them through to app-defined `OPTIONS` handlers.
4
+
5
+ ## Features
6
+
7
+ - **Preflight Handling** - Automatically handles `OPTIONS` preflight requests
8
+ - **Flexible Origin Rules** - Supports static, regex, list, and function-based origin policies
9
+ - **Credential Support** - Supports credentialed requests with spec-safe origin reflection
10
+ - **Header Controls** - Configure allowed and exposed headers, preflight methods, and max age
11
+ - **Private Network Support** - Optionally allow private network preflight requests
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ npm i remix
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { createRouter } from 'remix/fetch-router'
23
+ import { cors } from 'remix/cors-middleware'
24
+
25
+ let router = createRouter({
26
+ middleware: [
27
+ cors({
28
+ origin: ['https://app.example.com', 'https://admin.example.com'],
29
+ credentials: true,
30
+ exposedHeaders: ['X-Request-Id'],
31
+ }),
32
+ ],
33
+ })
34
+
35
+ router.get('/api/projects', () => {
36
+ return Response.json([{ id: 'p1', name: 'Remix' }], {
37
+ headers: {
38
+ 'X-Request-Id': 'req_123',
39
+ },
40
+ })
41
+ })
42
+ ```
43
+
44
+ ## Origin Policies
45
+
46
+ `origin` supports:
47
+
48
+ - `'*'` to allow all origins
49
+ - `string` for a single exact origin
50
+ - `RegExp` for pattern-based matching
51
+ - `Array<string | RegExp>` for multiple exact and pattern matches
52
+ - `true` to reflect the request origin
53
+ - `(origin, context) => boolean | string` for dynamic policies
54
+
55
+ ### Restrict Origins
56
+
57
+ ```ts
58
+ let router = createRouter({
59
+ middleware: [
60
+ cors({
61
+ origin: ['https://app.example.com', 'https://admin.example.com'],
62
+ credentials: true,
63
+ }),
64
+ ],
65
+ })
66
+ ```
67
+
68
+ ### Dynamic Origin Policies
69
+
70
+ ```ts
71
+ let router = createRouter({
72
+ middleware: [
73
+ cors({
74
+ origin(origin, context) {
75
+ if (context.url.pathname.startsWith('/public/')) {
76
+ return '*'
77
+ }
78
+
79
+ return origin.endsWith('.trusted.example')
80
+ },
81
+ }),
82
+ ],
83
+ })
84
+ ```
85
+
86
+ ## Preflight Behavior
87
+
88
+ By default, preflight requests are short-circuited with status `204`.
89
+
90
+ ```ts
91
+ let router = createRouter({
92
+ middleware: [
93
+ cors({
94
+ methods: ['GET', 'POST', 'PATCH'],
95
+ allowedHeaders: ['Authorization', 'Content-Type'],
96
+ maxAge: 600,
97
+ }),
98
+ ],
99
+ })
100
+ ```
101
+
102
+ Use a function-based `allowedHeaders` policy when the header allowlist depends on the request:
103
+
104
+ ```ts
105
+ let router = createRouter({
106
+ middleware: [
107
+ cors({
108
+ allowedHeaders(request) {
109
+ let requestedHeaders = request.headers.get('Access-Control-Request-Headers')
110
+
111
+ if (requestedHeaders?.includes('x-admin-token')) {
112
+ return ['Authorization', 'Content-Type', 'X-Admin-Token']
113
+ }
114
+
115
+ return ['Authorization', 'Content-Type']
116
+ },
117
+ }),
118
+ ],
119
+ })
120
+ ```
121
+
122
+ Function-based `allowedHeaders` responses vary on `Access-Control-Request-Headers`, so caches do not reuse a preflight response for a different requested-header set.
123
+
124
+ Set `preflightContinue: true` to let downstream handlers process preflight requests. Use `preflightStatusCode` when you want short-circuited preflight responses to return a status other than `204`.
125
+
126
+ ## Private Network Preflights
127
+
128
+ ```ts
129
+ let router = createRouter({
130
+ middleware: [
131
+ cors({
132
+ allowPrivateNetwork: true,
133
+ }),
134
+ ],
135
+ })
136
+ ```
137
+
138
+ When `allowPrivateNetwork` is enabled, the middleware adds `Access-Control-Allow-Private-Network: true` for preflight requests that ask for private network access.
139
+
140
+ ## Expose Response Headers
141
+
142
+ ```ts
143
+ let router = createRouter({
144
+ middleware: [
145
+ cors({
146
+ exposedHeaders: ['X-Request-Id', 'X-Trace-Id'],
147
+ }),
148
+ ],
149
+ })
150
+ ```
151
+
152
+ ## Caveats
153
+
154
+ - CORS is primarily a browser enforcement mechanism. Disallowed non-preflight requests still reach your handlers unless you add separate request validation.
155
+ - When `credentials: true` is used with `origin: '*'`, the middleware reflects the request origin and adds `Vary: Origin` so the response stays cache-safe.
156
+ - When `allowedHeaders` is a function, preflight responses vary on `Access-Control-Request-Headers` so caches do not reuse a response for a different requested-header set.
157
+ - `preflightContinue` and `preflightStatusCode` only affect how preflight `OPTIONS` requests are handled. They do not change actual request authorization.
158
+
159
+ ## Related Packages
160
+
161
+ - [`cop-middleware`](https://github.com/remix-run/remix/tree/main/packages/cop-middleware) - Browser-origin protection middleware for unsafe cross-origin requests
162
+ - [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Router for the web Fetch API
163
+ - [`headers`](https://github.com/remix-run/remix/tree/main/packages/headers) - Typed HTTP header utilities
164
+
165
+ ## Related Work
166
+
167
+ - [MDN: Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
168
+ - [Fetch Standard: CORS protocol](https://fetch.spec.whatwg.org/#http-cors-protocol)
169
+ - [expressjs/cors](https://github.com/expressjs/cors)
170
+ - [rack-cors](https://github.com/cyu/rack-cors)
171
+
172
+ ## License
173
+
174
+ See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
@@ -0,0 +1,2 @@
1
+ export { cors, type CorsAllowedHeadersResolver, type CorsAllowedHeadersResolverResult, type CorsOptions, type CorsOrigin, type CorsOriginResolver, type CorsOriginResolverResult, } from './lib/cors.ts';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,KAAK,0BAA0B,EAC/B,KAAK,gCAAgC,EACrC,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,wBAAwB,GAC9B,MAAM,eAAe,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { cors, } from "./lib/cors.js";
@@ -0,0 +1,100 @@
1
+ import type { Middleware, RequestContext } from '@remix-run/fetch-router';
2
+ type OriginMatcher = string | RegExp | ReadonlyArray<string | RegExp>;
3
+ /**
4
+ * Return shape for a dynamic CORS origin resolver.
5
+ */
6
+ export type CorsOriginResolverResult = '*' | string | boolean | null | undefined;
7
+ /**
8
+ * Resolves the allowed origin for a given request origin.
9
+ */
10
+ export interface CorsOriginResolver {
11
+ /**
12
+ * Resolves the allowed origin for a request with an `Origin` header.
13
+ */
14
+ (origin: string, context: RequestContext): CorsOriginResolverResult | Promise<CorsOriginResolverResult>;
15
+ }
16
+ /**
17
+ * Accepted forms for configuring allowed CORS origins.
18
+ */
19
+ export type CorsOrigin = OriginMatcher | boolean | CorsOriginResolver;
20
+ /**
21
+ * Return shape for a dynamic allowed-headers resolver.
22
+ */
23
+ export type CorsAllowedHeadersResolverResult = readonly string[] | null | undefined;
24
+ /**
25
+ * Resolves the allowed request headers for a preflight request.
26
+ */
27
+ export interface CorsAllowedHeadersResolver {
28
+ /**
29
+ * Resolves the request headers allowed by a preflight request.
30
+ */
31
+ (request: Request, context: RequestContext): CorsAllowedHeadersResolverResult | Promise<CorsAllowedHeadersResolverResult>;
32
+ }
33
+ /**
34
+ * Options for CORS middleware.
35
+ */
36
+ export interface CorsOptions {
37
+ /**
38
+ * Allowed origins. Defaults to '*'.
39
+ *
40
+ * - `true` reflects the request Origin
41
+ * - `false` disables CORS headers
42
+ * - `'*'` allows all origins
43
+ * - `string`/`RegExp`/array allow matching origins
44
+ * - `function` allows dynamic origin checks
45
+ */
46
+ origin?: CorsOrigin;
47
+ /**
48
+ * Allowed methods for preflight responses.
49
+ *
50
+ * @default ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
51
+ */
52
+ methods?: readonly string[];
53
+ /**
54
+ * Allowed request headers for preflight responses.
55
+ *
56
+ * Defaults to reflecting Access-Control-Request-Headers.
57
+ */
58
+ allowedHeaders?: readonly string[] | CorsAllowedHeadersResolver;
59
+ /**
60
+ * Exposed response headers for non-preflight requests.
61
+ */
62
+ exposedHeaders?: readonly string[];
63
+ /**
64
+ * Include Access-Control-Allow-Credentials: true.
65
+ *
66
+ * @default false
67
+ */
68
+ credentials?: boolean;
69
+ /**
70
+ * Access-Control-Max-Age value for preflight responses (seconds).
71
+ */
72
+ maxAge?: number;
73
+ /**
74
+ * Continue to downstream handlers for preflight requests.
75
+ *
76
+ * @default false
77
+ */
78
+ preflightContinue?: boolean;
79
+ /**
80
+ * Status code to use when short-circuiting preflight responses.
81
+ *
82
+ * @default 204
83
+ */
84
+ preflightStatusCode?: number;
85
+ /**
86
+ * Include Access-Control-Allow-Private-Network: true when requested.
87
+ *
88
+ * @default false
89
+ */
90
+ allowPrivateNetwork?: boolean;
91
+ }
92
+ /**
93
+ * Middleware that adds CORS headers and handles CORS preflight requests.
94
+ *
95
+ * @param options CORS options
96
+ * @returns CORS middleware
97
+ */
98
+ export declare function cors(options?: CorsOptions): Middleware;
99
+ export {};
100
+ //# sourceMappingURL=cors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cors.d.ts","sourceRoot":"","sources":["../../src/lib/cors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAA;AAKzE,KAAK,aAAa,GAAG,MAAM,GAAG,MAAM,GAAG,aAAa,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;AAErE;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG,GAAG,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,SAAS,CAAA;AAEhF;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;OAEG;IACH,CACE,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,cAAc,GACtB,wBAAwB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAA;CAChE;AAED;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,aAAa,GAAG,OAAO,GAAG,kBAAkB,CAAA;AAErE;;GAEG;AACH,MAAM,MAAM,gCAAgC,GAAG,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,CAAA;AAEnF;;GAEG;AACH,MAAM,WAAW,0BAA0B;IACzC;;OAEG;IACH,CACE,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,GACtB,gCAAgC,GAAG,OAAO,CAAC,gCAAgC,CAAC,CAAA;CAChF;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,UAAU,CAAA;IAEnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAE3B;;;;OAIG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,0BAA0B,CAAA;IAE/D;;OAEG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAElC;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAA;IAErB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAE3B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAE5B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC9B;AAOD;;;;;GAKG;AACH,wBAAgB,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,UAAU,CA0F1D"}
@@ -0,0 +1,218 @@
1
+ import { Vary } from '@remix-run/headers';
2
+ const defaultCorsMethods = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'];
3
+ /**
4
+ * Middleware that adds CORS headers and handles CORS preflight requests.
5
+ *
6
+ * @param options CORS options
7
+ * @returns CORS middleware
8
+ */
9
+ export function cors(options = {}) {
10
+ let methods = normalizeMethodList(options.methods ?? defaultCorsMethods);
11
+ let exposedHeaders = options.exposedHeaders ? normalizeHeaderList(options.exposedHeaders) : '';
12
+ let allowCredentials = options.credentials ?? false;
13
+ let preflightContinue = options.preflightContinue ?? false;
14
+ let preflightStatusCode = options.preflightStatusCode ?? 204;
15
+ return async (context, next) => {
16
+ let requestOrigin = context.headers.get('Origin');
17
+ let preflightRequest = isPreflightRequest(context);
18
+ if (requestOrigin == null) {
19
+ if (preflightRequest && !preflightContinue) {
20
+ return new Response(null, { status: preflightStatusCode });
21
+ }
22
+ return next();
23
+ }
24
+ let allowedOrigin = await resolveAllowedOrigin(requestOrigin, context, options.origin);
25
+ if (allowedOrigin == null) {
26
+ if (preflightRequest && !preflightContinue) {
27
+ return new Response(null, { status: 403 });
28
+ }
29
+ return next();
30
+ }
31
+ let corsHeaders = new Headers();
32
+ let vary = new Vary();
33
+ let allowOriginHeader = allowedOrigin;
34
+ if (allowCredentials && allowedOrigin === '*') {
35
+ allowOriginHeader = requestOrigin;
36
+ }
37
+ corsHeaders.set('Access-Control-Allow-Origin', allowOriginHeader);
38
+ if (allowOriginHeader !== '*') {
39
+ vary.add('Origin');
40
+ }
41
+ if (allowCredentials) {
42
+ corsHeaders.set('Access-Control-Allow-Credentials', 'true');
43
+ }
44
+ if (preflightRequest) {
45
+ corsHeaders.set('Access-Control-Allow-Methods', methods);
46
+ vary.add('Access-Control-Request-Method');
47
+ let allowedHeaders = await resolveAllowedHeaders(context, options.allowedHeaders);
48
+ if (allowedHeaders.headerValue != null) {
49
+ corsHeaders.set('Access-Control-Allow-Headers', allowedHeaders.headerValue);
50
+ }
51
+ if (allowedHeaders.varyOnRequestHeaders) {
52
+ vary.add('Access-Control-Request-Headers');
53
+ }
54
+ if (options.maxAge != null) {
55
+ let maxAge = Math.max(0, Math.floor(options.maxAge));
56
+ corsHeaders.set('Access-Control-Max-Age', String(maxAge));
57
+ }
58
+ if (options.allowPrivateNetwork &&
59
+ context.headers.get('Access-Control-Request-Private-Network')?.toLowerCase() === 'true') {
60
+ corsHeaders.set('Access-Control-Allow-Private-Network', 'true');
61
+ vary.add('Access-Control-Request-Private-Network');
62
+ }
63
+ if (!preflightContinue) {
64
+ if (vary.size > 0) {
65
+ corsHeaders.set('Vary', vary.toString());
66
+ }
67
+ return new Response(null, {
68
+ status: preflightStatusCode,
69
+ headers: corsHeaders,
70
+ });
71
+ }
72
+ }
73
+ else if (exposedHeaders !== '') {
74
+ corsHeaders.set('Access-Control-Expose-Headers', exposedHeaders);
75
+ }
76
+ let response = await next();
77
+ return withCorsHeaders(response, corsHeaders, vary);
78
+ };
79
+ }
80
+ function isPreflightRequest(context) {
81
+ return context.method === 'OPTIONS' && context.headers.has('Access-Control-Request-Method');
82
+ }
83
+ function normalizeMethodList(methods) {
84
+ let normalized = [];
85
+ for (let method of methods) {
86
+ let value = method.trim().toUpperCase();
87
+ if (value === '') {
88
+ continue;
89
+ }
90
+ if (normalized.includes(value)) {
91
+ continue;
92
+ }
93
+ normalized.push(value);
94
+ }
95
+ return normalized.join(', ');
96
+ }
97
+ function normalizeHeaderList(headerNames) {
98
+ let normalized = [];
99
+ for (let headerName of headerNames) {
100
+ let value = headerName.trim();
101
+ if (value === '') {
102
+ continue;
103
+ }
104
+ let duplicate = normalized.some((existing) => existing.toLowerCase() === value.toLowerCase());
105
+ if (duplicate) {
106
+ continue;
107
+ }
108
+ normalized.push(value);
109
+ }
110
+ return normalized.join(', ');
111
+ }
112
+ async function resolveAllowedOrigin(requestOrigin, context, configuredOrigin) {
113
+ let origin = configuredOrigin ?? '*';
114
+ if (typeof origin === 'function') {
115
+ let result = await origin(requestOrigin, context);
116
+ return normalizeResolvedOrigin(result, requestOrigin);
117
+ }
118
+ if (origin === true) {
119
+ return requestOrigin;
120
+ }
121
+ if (origin === false) {
122
+ return null;
123
+ }
124
+ if (typeof origin === 'string') {
125
+ if (origin === '*') {
126
+ return '*';
127
+ }
128
+ return origin === requestOrigin ? requestOrigin : null;
129
+ }
130
+ if (origin instanceof RegExp) {
131
+ return matchesOriginPattern(origin, requestOrigin) ? requestOrigin : null;
132
+ }
133
+ for (let allowed of origin) {
134
+ if (allowed === '*') {
135
+ return '*';
136
+ }
137
+ if (typeof allowed === 'string' && allowed === requestOrigin) {
138
+ return requestOrigin;
139
+ }
140
+ if (allowed instanceof RegExp && matchesOriginPattern(allowed, requestOrigin)) {
141
+ return requestOrigin;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ function matchesOriginPattern(pattern, requestOrigin) {
147
+ let normalizedPattern = new RegExp(pattern.source, pattern.flags);
148
+ return normalizedPattern.test(requestOrigin);
149
+ }
150
+ function normalizeResolvedOrigin(resolved, requestOrigin) {
151
+ if (resolved == null || resolved === false) {
152
+ return null;
153
+ }
154
+ if (resolved === true) {
155
+ return requestOrigin;
156
+ }
157
+ if (resolved === '*') {
158
+ return '*';
159
+ }
160
+ return resolved;
161
+ }
162
+ async function resolveAllowedHeaders(context, configuredAllowedHeaders) {
163
+ if (Array.isArray(configuredAllowedHeaders)) {
164
+ let headerValue = normalizeHeaderList(configuredAllowedHeaders);
165
+ return {
166
+ headerValue: headerValue === '' ? null : headerValue,
167
+ varyOnRequestHeaders: false,
168
+ };
169
+ }
170
+ if (typeof configuredAllowedHeaders === 'function') {
171
+ let resolved = await configuredAllowedHeaders(context.request, context);
172
+ if (resolved != null) {
173
+ let headerValue = normalizeHeaderList(resolved);
174
+ return {
175
+ headerValue: headerValue === '' ? null : headerValue,
176
+ varyOnRequestHeaders: true,
177
+ };
178
+ }
179
+ let requestedHeaders = context.headers.get('Access-Control-Request-Headers');
180
+ if (requestedHeaders == null || requestedHeaders.trim() === '') {
181
+ return {
182
+ headerValue: null,
183
+ varyOnRequestHeaders: true,
184
+ };
185
+ }
186
+ return {
187
+ headerValue: requestedHeaders,
188
+ varyOnRequestHeaders: true,
189
+ };
190
+ }
191
+ let requestedHeaders = context.headers.get('Access-Control-Request-Headers');
192
+ if (requestedHeaders == null || requestedHeaders.trim() === '') {
193
+ return {
194
+ headerValue: null,
195
+ varyOnRequestHeaders: false,
196
+ };
197
+ }
198
+ return {
199
+ headerValue: requestedHeaders,
200
+ varyOnRequestHeaders: true,
201
+ };
202
+ }
203
+ function withCorsHeaders(response, corsHeaders, vary) {
204
+ let responseHeaders = new Headers(response.headers);
205
+ for (let [headerName, headerValue] of corsHeaders) {
206
+ responseHeaders.set(headerName, headerValue);
207
+ }
208
+ if (vary.size > 0) {
209
+ let responseVary = Vary.from(responseHeaders.get('Vary'));
210
+ vary.forEach((headerName) => responseVary.add(headerName));
211
+ responseHeaders.set('Vary', responseVary.toString());
212
+ }
213
+ return new Response(response.body, {
214
+ status: response.status,
215
+ statusText: response.statusText,
216
+ headers: responseHeaders,
217
+ });
218
+ }
package/package.json CHANGED
@@ -1,14 +1,54 @@
1
1
  {
2
2
  "name": "@remix-run/cors-middleware",
3
- "version": "0.0.0",
4
- "description": "Placeholder package for Remix CI/OIDC setup",
3
+ "version": "0.1.1",
4
+ "description": "Middleware for handling CORS in Fetch API servers",
5
+ "author": "Michael Jackson <mjijackson@gmail.com>",
5
6
  "license": "MIT",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/remix-run/remix.git",
9
10
  "directory": "packages/cors-middleware"
10
11
  },
11
- "publishConfig": {
12
- "access": "public"
12
+ "homepage": "https://github.com/remix-run/remix/tree/main/packages/cors-middleware#readme",
13
+ "files": [
14
+ "LICENSE",
15
+ "README.md",
16
+ "dist",
17
+ "src",
18
+ "!src/**/*.test.ts"
19
+ ],
20
+ "type": "module",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.6.0",
30
+ "@typescript/native-preview": "7.0.0-dev.20251125.1",
31
+ "@remix-run/assert": "0.1.0",
32
+ "@remix-run/test": "0.1.0",
33
+ "@remix-run/headers": "0.19.0",
34
+ "@remix-run/fetch-router": "0.18.1"
35
+ },
36
+ "dependencies": {
37
+ "@remix-run/headers": "^0.19.0",
38
+ "@remix-run/fetch-router": "^0.18.1"
39
+ },
40
+ "keywords": [
41
+ "fetch",
42
+ "router",
43
+ "middleware",
44
+ "cors",
45
+ "cross-origin",
46
+ "preflight"
47
+ ],
48
+ "scripts": {
49
+ "build": "tsgo -p tsconfig.build.json",
50
+ "clean": "git clean -fdX",
51
+ "test": "remix-test",
52
+ "typecheck": "tsgo --noEmit"
13
53
  }
14
- }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export {
2
+ cors,
3
+ type CorsAllowedHeadersResolver,
4
+ type CorsAllowedHeadersResolverResult,
5
+ type CorsOptions,
6
+ type CorsOrigin,
7
+ type CorsOriginResolver,
8
+ type CorsOriginResolverResult,
9
+ } from './lib/cors.ts'
@@ -0,0 +1,406 @@
1
+ import type { Middleware, RequestContext } from '@remix-run/fetch-router'
2
+ import { Vary } from '@remix-run/headers'
3
+
4
+ const defaultCorsMethods = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
5
+
6
+ type OriginMatcher = string | RegExp | ReadonlyArray<string | RegExp>
7
+
8
+ /**
9
+ * Return shape for a dynamic CORS origin resolver.
10
+ */
11
+ export type CorsOriginResolverResult = '*' | string | boolean | null | undefined
12
+
13
+ /**
14
+ * Resolves the allowed origin for a given request origin.
15
+ */
16
+ export interface CorsOriginResolver {
17
+ /**
18
+ * Resolves the allowed origin for a request with an `Origin` header.
19
+ */
20
+ (
21
+ origin: string,
22
+ context: RequestContext,
23
+ ): CorsOriginResolverResult | Promise<CorsOriginResolverResult>
24
+ }
25
+
26
+ /**
27
+ * Accepted forms for configuring allowed CORS origins.
28
+ */
29
+ export type CorsOrigin = OriginMatcher | boolean | CorsOriginResolver
30
+
31
+ /**
32
+ * Return shape for a dynamic allowed-headers resolver.
33
+ */
34
+ export type CorsAllowedHeadersResolverResult = readonly string[] | null | undefined
35
+
36
+ /**
37
+ * Resolves the allowed request headers for a preflight request.
38
+ */
39
+ export interface CorsAllowedHeadersResolver {
40
+ /**
41
+ * Resolves the request headers allowed by a preflight request.
42
+ */
43
+ (
44
+ request: Request,
45
+ context: RequestContext,
46
+ ): CorsAllowedHeadersResolverResult | Promise<CorsAllowedHeadersResolverResult>
47
+ }
48
+
49
+ /**
50
+ * Options for CORS middleware.
51
+ */
52
+ export interface CorsOptions {
53
+ /**
54
+ * Allowed origins. Defaults to '*'.
55
+ *
56
+ * - `true` reflects the request Origin
57
+ * - `false` disables CORS headers
58
+ * - `'*'` allows all origins
59
+ * - `string`/`RegExp`/array allow matching origins
60
+ * - `function` allows dynamic origin checks
61
+ */
62
+ origin?: CorsOrigin
63
+
64
+ /**
65
+ * Allowed methods for preflight responses.
66
+ *
67
+ * @default ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
68
+ */
69
+ methods?: readonly string[]
70
+
71
+ /**
72
+ * Allowed request headers for preflight responses.
73
+ *
74
+ * Defaults to reflecting Access-Control-Request-Headers.
75
+ */
76
+ allowedHeaders?: readonly string[] | CorsAllowedHeadersResolver
77
+
78
+ /**
79
+ * Exposed response headers for non-preflight requests.
80
+ */
81
+ exposedHeaders?: readonly string[]
82
+
83
+ /**
84
+ * Include Access-Control-Allow-Credentials: true.
85
+ *
86
+ * @default false
87
+ */
88
+ credentials?: boolean
89
+
90
+ /**
91
+ * Access-Control-Max-Age value for preflight responses (seconds).
92
+ */
93
+ maxAge?: number
94
+
95
+ /**
96
+ * Continue to downstream handlers for preflight requests.
97
+ *
98
+ * @default false
99
+ */
100
+ preflightContinue?: boolean
101
+
102
+ /**
103
+ * Status code to use when short-circuiting preflight responses.
104
+ *
105
+ * @default 204
106
+ */
107
+ preflightStatusCode?: number
108
+
109
+ /**
110
+ * Include Access-Control-Allow-Private-Network: true when requested.
111
+ *
112
+ * @default false
113
+ */
114
+ allowPrivateNetwork?: boolean
115
+ }
116
+
117
+ type ResolvedAllowedHeaders = {
118
+ headerValue: string | null
119
+ varyOnRequestHeaders: boolean
120
+ }
121
+
122
+ /**
123
+ * Middleware that adds CORS headers and handles CORS preflight requests.
124
+ *
125
+ * @param options CORS options
126
+ * @returns CORS middleware
127
+ */
128
+ export function cors(options: CorsOptions = {}): Middleware {
129
+ let methods = normalizeMethodList(options.methods ?? defaultCorsMethods)
130
+ let exposedHeaders = options.exposedHeaders ? normalizeHeaderList(options.exposedHeaders) : ''
131
+ let allowCredentials = options.credentials ?? false
132
+ let preflightContinue = options.preflightContinue ?? false
133
+ let preflightStatusCode = options.preflightStatusCode ?? 204
134
+
135
+ return async (context, next) => {
136
+ let requestOrigin = context.headers.get('Origin')
137
+ let preflightRequest = isPreflightRequest(context)
138
+
139
+ if (requestOrigin == null) {
140
+ if (preflightRequest && !preflightContinue) {
141
+ return new Response(null, { status: preflightStatusCode })
142
+ }
143
+
144
+ return next()
145
+ }
146
+
147
+ let allowedOrigin = await resolveAllowedOrigin(requestOrigin, context, options.origin)
148
+ if (allowedOrigin == null) {
149
+ if (preflightRequest && !preflightContinue) {
150
+ return new Response(null, { status: 403 })
151
+ }
152
+
153
+ return next()
154
+ }
155
+
156
+ let corsHeaders = new Headers()
157
+ let vary = new Vary()
158
+
159
+ let allowOriginHeader = allowedOrigin
160
+ if (allowCredentials && allowedOrigin === '*') {
161
+ allowOriginHeader = requestOrigin
162
+ }
163
+
164
+ corsHeaders.set('Access-Control-Allow-Origin', allowOriginHeader)
165
+
166
+ if (allowOriginHeader !== '*') {
167
+ vary.add('Origin')
168
+ }
169
+
170
+ if (allowCredentials) {
171
+ corsHeaders.set('Access-Control-Allow-Credentials', 'true')
172
+ }
173
+
174
+ if (preflightRequest) {
175
+ corsHeaders.set('Access-Control-Allow-Methods', methods)
176
+ vary.add('Access-Control-Request-Method')
177
+
178
+ let allowedHeaders = await resolveAllowedHeaders(context, options.allowedHeaders)
179
+ if (allowedHeaders.headerValue != null) {
180
+ corsHeaders.set('Access-Control-Allow-Headers', allowedHeaders.headerValue)
181
+ }
182
+
183
+ if (allowedHeaders.varyOnRequestHeaders) {
184
+ vary.add('Access-Control-Request-Headers')
185
+ }
186
+
187
+ if (options.maxAge != null) {
188
+ let maxAge = Math.max(0, Math.floor(options.maxAge))
189
+ corsHeaders.set('Access-Control-Max-Age', String(maxAge))
190
+ }
191
+
192
+ if (
193
+ options.allowPrivateNetwork &&
194
+ context.headers.get('Access-Control-Request-Private-Network')?.toLowerCase() === 'true'
195
+ ) {
196
+ corsHeaders.set('Access-Control-Allow-Private-Network', 'true')
197
+ vary.add('Access-Control-Request-Private-Network')
198
+ }
199
+
200
+ if (!preflightContinue) {
201
+ if (vary.size > 0) {
202
+ corsHeaders.set('Vary', vary.toString())
203
+ }
204
+
205
+ return new Response(null, {
206
+ status: preflightStatusCode,
207
+ headers: corsHeaders,
208
+ })
209
+ }
210
+ } else if (exposedHeaders !== '') {
211
+ corsHeaders.set('Access-Control-Expose-Headers', exposedHeaders)
212
+ }
213
+
214
+ let response = await next()
215
+
216
+ return withCorsHeaders(response, corsHeaders, vary)
217
+ }
218
+ }
219
+
220
+ function isPreflightRequest(context: RequestContext): boolean {
221
+ return context.method === 'OPTIONS' && context.headers.has('Access-Control-Request-Method')
222
+ }
223
+
224
+ function normalizeMethodList(methods: readonly string[]): string {
225
+ let normalized: string[] = []
226
+
227
+ for (let method of methods) {
228
+ let value = method.trim().toUpperCase()
229
+ if (value === '') {
230
+ continue
231
+ }
232
+
233
+ if (normalized.includes(value)) {
234
+ continue
235
+ }
236
+
237
+ normalized.push(value)
238
+ }
239
+
240
+ return normalized.join(', ')
241
+ }
242
+
243
+ function normalizeHeaderList(headerNames: readonly string[]): string {
244
+ let normalized: string[] = []
245
+
246
+ for (let headerName of headerNames) {
247
+ let value = headerName.trim()
248
+ if (value === '') {
249
+ continue
250
+ }
251
+
252
+ let duplicate = normalized.some((existing) => existing.toLowerCase() === value.toLowerCase())
253
+ if (duplicate) {
254
+ continue
255
+ }
256
+
257
+ normalized.push(value)
258
+ }
259
+
260
+ return normalized.join(', ')
261
+ }
262
+
263
+ async function resolveAllowedOrigin(
264
+ requestOrigin: string,
265
+ context: RequestContext,
266
+ configuredOrigin: CorsOrigin | undefined,
267
+ ): Promise<string | '*' | null> {
268
+ let origin = configuredOrigin ?? '*'
269
+
270
+ if (typeof origin === 'function') {
271
+ let result = await origin(requestOrigin, context)
272
+ return normalizeResolvedOrigin(result, requestOrigin)
273
+ }
274
+
275
+ if (origin === true) {
276
+ return requestOrigin
277
+ }
278
+
279
+ if (origin === false) {
280
+ return null
281
+ }
282
+
283
+ if (typeof origin === 'string') {
284
+ if (origin === '*') {
285
+ return '*'
286
+ }
287
+
288
+ return origin === requestOrigin ? requestOrigin : null
289
+ }
290
+
291
+ if (origin instanceof RegExp) {
292
+ return matchesOriginPattern(origin, requestOrigin) ? requestOrigin : null
293
+ }
294
+
295
+ for (let allowed of origin) {
296
+ if (allowed === '*') {
297
+ return '*'
298
+ }
299
+
300
+ if (typeof allowed === 'string' && allowed === requestOrigin) {
301
+ return requestOrigin
302
+ }
303
+
304
+ if (allowed instanceof RegExp && matchesOriginPattern(allowed, requestOrigin)) {
305
+ return requestOrigin
306
+ }
307
+ }
308
+
309
+ return null
310
+ }
311
+
312
+ function matchesOriginPattern(pattern: RegExp, requestOrigin: string): boolean {
313
+ let normalizedPattern = new RegExp(pattern.source, pattern.flags)
314
+ return normalizedPattern.test(requestOrigin)
315
+ }
316
+
317
+ function normalizeResolvedOrigin(
318
+ resolved: CorsOriginResolverResult,
319
+ requestOrigin: string,
320
+ ): string | '*' | null {
321
+ if (resolved == null || resolved === false) {
322
+ return null
323
+ }
324
+
325
+ if (resolved === true) {
326
+ return requestOrigin
327
+ }
328
+
329
+ if (resolved === '*') {
330
+ return '*'
331
+ }
332
+
333
+ return resolved
334
+ }
335
+
336
+ async function resolveAllowedHeaders(
337
+ context: RequestContext,
338
+ configuredAllowedHeaders: readonly string[] | CorsAllowedHeadersResolver | undefined,
339
+ ): Promise<ResolvedAllowedHeaders> {
340
+ if (Array.isArray(configuredAllowedHeaders)) {
341
+ let headerValue = normalizeHeaderList(configuredAllowedHeaders)
342
+
343
+ return {
344
+ headerValue: headerValue === '' ? null : headerValue,
345
+ varyOnRequestHeaders: false,
346
+ }
347
+ }
348
+
349
+ if (typeof configuredAllowedHeaders === 'function') {
350
+ let resolved = await configuredAllowedHeaders(context.request, context)
351
+ if (resolved != null) {
352
+ let headerValue = normalizeHeaderList(resolved)
353
+
354
+ return {
355
+ headerValue: headerValue === '' ? null : headerValue,
356
+ varyOnRequestHeaders: true,
357
+ }
358
+ }
359
+
360
+ let requestedHeaders = context.headers.get('Access-Control-Request-Headers')
361
+ if (requestedHeaders == null || requestedHeaders.trim() === '') {
362
+ return {
363
+ headerValue: null,
364
+ varyOnRequestHeaders: true,
365
+ }
366
+ }
367
+
368
+ return {
369
+ headerValue: requestedHeaders,
370
+ varyOnRequestHeaders: true,
371
+ }
372
+ }
373
+
374
+ let requestedHeaders = context.headers.get('Access-Control-Request-Headers')
375
+ if (requestedHeaders == null || requestedHeaders.trim() === '') {
376
+ return {
377
+ headerValue: null,
378
+ varyOnRequestHeaders: false,
379
+ }
380
+ }
381
+
382
+ return {
383
+ headerValue: requestedHeaders,
384
+ varyOnRequestHeaders: true,
385
+ }
386
+ }
387
+
388
+ function withCorsHeaders(response: Response, corsHeaders: Headers, vary: Vary): Response {
389
+ let responseHeaders = new Headers(response.headers)
390
+
391
+ for (let [headerName, headerValue] of corsHeaders) {
392
+ responseHeaders.set(headerName, headerValue)
393
+ }
394
+
395
+ if (vary.size > 0) {
396
+ let responseVary = Vary.from(responseHeaders.get('Vary'))
397
+ vary.forEach((headerName) => responseVary.add(headerName))
398
+ responseHeaders.set('Vary', responseVary.toString())
399
+ }
400
+
401
+ return new Response(response.body, {
402
+ status: response.status,
403
+ statusText: response.statusText,
404
+ headers: responseHeaders,
405
+ })
406
+ }