Skip to content

Commit 523e6d3

Browse files
committed
add cors middleware
1 parent c50b03e commit 523e6d3

File tree

7 files changed

+271
-38
lines changed

7 files changed

+271
-38
lines changed

README.md

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ See [full example](src/examples/micro.ts).
7474

7575
### Matchers
7676

77-
In the core, matchers are responsible to decide if particular handler should be called or not. There is no magic: matchers are interated on every request and first positive "match" calls defined handler.
77+
In the core, matchers are responsible to decide if particular handler should be called or not. There is no magic: matchers are iterated on every request and first positive "match" calls defined handler.
7878

7979
#### MethodMatcher ([source](./src/matchers/MethodMatcher.ts))
8080

@@ -146,18 +146,49 @@ router.addRoute({
146146
})
147147
```
148148

149-
### Middleware
149+
### Middlewares
150+
151+
**This section is highly experimental!**
150152

151153
Currently, there is no built-in API for middlewares. It seems like there is no aproach to provide centralized and typesafe way for middlewares. And it need some conceptual work, before it will be added. Open an issue, if you have a great idea!
152154

153-
But well, handler can be wrapped like:
155+
#### CorsMiddleware ([source](./src/middlewares/CorsMiddleware.ts))
156+
157+
Example of CorsMiddleware usage:
158+
159+
```typescript
160+
const corsMiddleware = CorsMiddleware({
161+
origins: corsOrigins,
162+
})
163+
```
164+
165+
Available options:
166+
167+
```typescript
168+
interface CorsMiddlewareOptions {
169+
// exact origins like 'http://0.0.0.0:8080' or '*'
170+
origins: string[],
171+
// methods like 'POST', 'GET' etc.
172+
allowMethods?: HttpMethod[]
173+
// headers like 'Authorization' or 'X-Requested-With'
174+
allowHeaders?: string[]
175+
// allows cookies in CORS scenario
176+
allowCredentials?: boolean
177+
// max age in seconds
178+
maxAge?: number
179+
}
180+
```
181+
182+
See source file for defaults.
183+
184+
#### Create own middleware
154185

155186
```typescript
156187
// example of a generic middleware, not a cors middleware!
157-
function corsMiddleware(origin: string) {
158-
return function corsWrapper<T extends MatchResult>(
159-
wrappedHandler: Handler<T>,
160-
): Handler<T> {
188+
function CorsMiddleware(origin: string) {
189+
return function corsWrapper<T extends MatchResult, D extends Matched<T>>(
190+
wrappedHandler: Handler<T, D>,
191+
): Handler<T, D> {
161192
return async function corsHandler(req, res, ...args) {
162193
// -> executed before handler
163194
// it's even possible to skip the handler at all
@@ -170,7 +201,7 @@ function corsMiddleware(origin: string) {
170201
}
171202

172203
// create a configured instance of middleware
173-
const cors = corsMiddleware('http://0.0.0.0:8080')
204+
const cors = CorsMiddleware('http://0.0.0.0:8080')
174205

175206
router.addRoute({
176207
matcher: new MethodMatcher(['OPTIONS', 'POST']),
@@ -179,32 +210,10 @@ router.addRoute({
179210
})
180211
```
181212

182-
Of course you can create a `middlewares` wrapper and put all middlewares inside it:
183-
```typescript
184-
type Middleware<T extends (handler: Handler<MatchResult>) => Handler<MatchResult>> = Parameters<Parameters<T>[0]>[2]
185-
186-
function middlewares<T extends MatchResult>(
187-
handler: Handler<T, Matched<T>
188-
& Middleware<typeof session>
189-
& Middleware<typeof cors>>,
190-
): Handler<T> {
191-
return function middlewaresHandler(...args) {
192-
// @ts-expect-error
193-
return cors(session(handler(...args)))
194-
}
195-
}
196-
197-
router.addRoute({
198-
matcher,
199-
// use it
200-
handler: middlewares((req, res, { csrftoken }) => `Token: ${csrftoken}`),
201-
})
202-
```
203-
204213
Apropos typesafety. You can modify types in middleware:
205214

206215
```typescript
207-
function valueMiddleware(myValue: string) {
216+
function ValueMiddleware(myValue: string) {
208217
return function valueWrapper<T extends MatchResult>(
209218
handler: Handler<T, Matched<T> & {
210219
// add additional type
@@ -221,14 +230,37 @@ function valueMiddleware(myValue: string) {
221230
}
222231
}
223232

224-
const value = valueMiddleware('world')
233+
const value = ValueMiddleware('world')
225234

226235
router.addRoute({
227236
matcher: new MethodMatcher(['GET']),
228237
handler: value((req, res, { myValue }) => `Hello ${myValue}`),
229238
})
230239
```
231240

241+
#### DRY approach
242+
243+
Of course you can create a `middlewares` wrapper and put all middlewares inside it:
244+
```typescript
245+
type Middleware<T extends (handler: Handler<MatchResult>) => Handler<MatchResult>> = Parameters<Parameters<T>[0]>[2]
246+
247+
function middlewares<T extends MatchResult>(
248+
handler: Handler<T, Matched<T>
249+
& Middleware<typeof session>
250+
& Middleware<typeof cors>>,
251+
): Handler<T> {
252+
return function middlewaresHandler(...args) {
253+
return cors(session(handler))(...args)
254+
}
255+
}
256+
257+
router.addRoute({
258+
matcher,
259+
// use it
260+
handler: middlewares((req, res, { csrftoken }) => `Token: ${csrftoken}`),
261+
})
262+
```
263+
232264
## License
233265

234266
MIT License

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module.exports = {
55
transform: {
66
'^.+\\.tsx?$': 'ts-jest',
77
},
8-
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
8+
testRegex: '(/__tests__/.*|(\\.|/)test)\\.tsx?$',
99
testPathIgnorePatterns: ['/node_modules/'],
1010
moduleFileExtensions: [...defaults.moduleFileExtensions, 'ts', 'tsx'],
1111
coverageThreshold: {

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bessonovs/node-http-router",
3-
"version": "0.0.9",
3+
"version": "1.0.0",
44
"description": "Extensible http router for node and micro",
55
"keywords": [
66
"router",
@@ -41,11 +41,11 @@
4141
"@types/express": "4.17.13",
4242
"@types/jest": "27.4.1",
4343
"@types/node": "16.11.7",
44-
"@typescript-eslint/eslint-plugin": "5.17.0",
45-
"@typescript-eslint/parser": "5.17.0",
46-
"eslint": "8.12.0",
44+
"@typescript-eslint/eslint-plugin": "5.20.0",
45+
"@typescript-eslint/parser": "5.20.0",
46+
"eslint": "8.13.0",
4747
"eslint-config-airbnb": "19.0.4",
48-
"eslint-plugin-import": "2.25.4",
48+
"eslint-plugin-import": "2.26.0",
4949
"eslint-plugin-jsx-a11y": "6.5.1",
5050
"eslint-plugin-react": "7.29.4",
5151
"jest": "27.5.1",

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './matchers'
2+
export * from './middlewares'
23
export type {
34
Handler,
45
Route,

src/middlewares/CorsMiddleware.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
MatchResult,
3+
Matched,
4+
} from '../matchers/MatchResult'
5+
import {
6+
Handler,
7+
} from '../router'
8+
9+
type HttpMethod =
10+
| 'POST'
11+
| 'GET'
12+
| 'PUT'
13+
| 'PATCH'
14+
| 'DELETE'
15+
| 'OPTIONS'
16+
17+
const DEFAULT_ALLOWED_METHODS: HttpMethod[] = [
18+
'POST',
19+
'GET',
20+
'PUT',
21+
'PATCH',
22+
'DELETE',
23+
'OPTIONS',
24+
]
25+
26+
const DEFAULT_ALLOWED_HEADERS = [
27+
'X-Requested-With',
28+
'Access-Control-Allow-Origin',
29+
'Content-Type',
30+
'Authorization',
31+
'Accept',
32+
]
33+
34+
const DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 // 24 hours
35+
36+
interface CorsMiddlewareOptions {
37+
// exact origins like 'http://0.0.0.0:8080' or '*'
38+
origins: string[]
39+
// methods like 'POST', 'GET' etc.
40+
allowMethods?: HttpMethod[]
41+
// headers like 'Authorization' or 'X-Requested-With'
42+
allowHeaders?: string[]
43+
// allows cookies in CORS scenario
44+
allowCredentials?: boolean
45+
// max age in seconds
46+
maxAge?: number
47+
}
48+
49+
export function CorsMiddleware({
50+
origins: originConfig,
51+
allowMethods = DEFAULT_ALLOWED_METHODS,
52+
allowHeaders = DEFAULT_ALLOWED_HEADERS,
53+
allowCredentials = true,
54+
maxAge = DEFAULT_MAX_AGE_SECONDS,
55+
}: CorsMiddlewareOptions) {
56+
return function corsWrapper<T extends MatchResult, D extends Matched<T>>(
57+
handler: Handler<T, D>,
58+
): Handler<T, D> {
59+
return async function corsHandler(req, res, ...args) {
60+
// avoid "Cannot set headers after they are sent to the client"
61+
if (res.writableEnded) {
62+
// TODO: not sure if handler should be called
63+
return handler(req, res, ...args)
64+
}
65+
66+
const origin = req.headers.origin ?? ''
67+
if (originConfig.includes(origin) || originConfig.includes('*')) {
68+
res.setHeader('Access-Control-Allow-Origin', origin)
69+
res.setHeader('Vary', 'Origin')
70+
if (allowCredentials) {
71+
res.setHeader('Access-Control-Allow-Credentials', 'true')
72+
}
73+
}
74+
75+
if (req.method === 'OPTIONS') {
76+
if (allowMethods.length) {
77+
res.setHeader('Access-Control-Allow-Methods', allowMethods.join(','))
78+
}
79+
if (allowHeaders.length) {
80+
res.setHeader('Access-Control-Allow-Headers', allowHeaders.join(','))
81+
}
82+
if (maxAge) {
83+
res.setHeader('Access-Control-Max-Age', String(maxAge))
84+
}
85+
// no further processing of preflight requests
86+
res.statusCode = 200
87+
res.end()
88+
// eslint-disable-next-line consistent-return
89+
return
90+
}
91+
92+
const result = await handler(req, res, ...args)
93+
return result
94+
}
95+
}
96+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
createRequest,
3+
createResponse,
4+
} from 'node-mocks-http'
5+
import {
6+
CorsMiddleware,
7+
} from '../CorsMiddleware'
8+
9+
describe('simple configuration', () => {
10+
beforeEach(() => {
11+
jest.resetAllMocks()
12+
})
13+
14+
const innerHandler = jest.fn()
15+
const handler = CorsMiddleware({
16+
origins: ['http://0.0.0.0:8000'],
17+
})(innerHandler)
18+
19+
it('no action', async () => {
20+
const req = createRequest({
21+
method: 'GET',
22+
headers: {
23+
origin: 'http://0.0.0.0:8000',
24+
},
25+
})
26+
const res = createResponse()
27+
await handler(req, res, { matched: true })
28+
expect(innerHandler).toBeCalledTimes(1)
29+
expect(res.getHeader('Access-Control-Allow-Methods')).toBeUndefined()
30+
expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http://0.0.0.0:8000')
31+
})
32+
33+
it('cors request', async () => {
34+
const req = createRequest({
35+
method: 'OPTIONS',
36+
headers: {
37+
origin: 'http://0.0.0.0:8000',
38+
},
39+
})
40+
const res = createResponse()
41+
await handler(req, res, { matched: true })
42+
expect(innerHandler).toBeCalledTimes(0)
43+
expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,GET,PUT,PATCH,DELETE,OPTIONS')
44+
expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http://0.0.0.0:8000')
45+
expect(res.getHeader('Access-Control-Allow-Credentials')).toBe('true')
46+
})
47+
48+
it('without origin', async () => {
49+
const req = createRequest({
50+
method: 'OPTIONS',
51+
})
52+
const res = createResponse()
53+
await handler(req, res, { matched: true })
54+
expect(innerHandler).toBeCalledTimes(0)
55+
expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,GET,PUT,PATCH,DELETE,OPTIONS')
56+
expect(res.getHeader('Access-Control-Allow-Origin')).toBeUndefined()
57+
})
58+
59+
it('request was ended before', async () => {
60+
const req = createRequest({
61+
method: 'GET',
62+
})
63+
const res = createResponse()
64+
res.end()
65+
await handler(req, res, { matched: true })
66+
expect(innerHandler).toBeCalledTimes(1)
67+
expect(res.getHeader('Access-Control-Allow-Methods')).toBeUndefined()
68+
expect(res.getHeader('Access-Control-Allow-Origin')).toBeUndefined()
69+
})
70+
})
71+
72+
describe('changed defaults', () => {
73+
beforeEach(() => {
74+
jest.resetAllMocks()
75+
})
76+
77+
const innerHandler = jest.fn()
78+
const handler = CorsMiddleware({
79+
origins: ['*'],
80+
allowMethods: ['POST', 'DELETE'],
81+
allowHeaders: ['Authorization'],
82+
allowCredentials: false,
83+
maxAge: 360,
84+
})(innerHandler)
85+
86+
it('no action', async () => {
87+
const req = createRequest({
88+
method: 'OPTIONS',
89+
headers: {
90+
origin: 'http:/idontcare:80',
91+
},
92+
})
93+
const res = createResponse()
94+
await handler(req, res, { matched: true })
95+
expect(innerHandler).toBeCalledTimes(0)
96+
expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,DELETE')
97+
expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http:/idontcare:80')
98+
expect(res.getHeader('Access-Control-Max-Age')).toBe('360')
99+
expect(res.getHeader('Access-Control-Allow-Credentials')).toBeUndefined()
100+
})
101+
})

src/middlewares/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export {
2+
CorsMiddleware,
3+
} from './CorsMiddleware'

0 commit comments

Comments
 (0)