Skip to content

Commit

Permalink
v5.x DRAFT: CORS rewrite (#226)
Browse files Browse the repository at this point in the history
* flowrouter is the new router and ittyrouter is the old router

* further evolving the standard

* added shared router tests for feature parity between IttyRouter and Rotuer

* take that, linter!

* cleaned up example

* added AutoRouter test coverage

* lint fix

* types for before/after/onError

* released v4.3.0-next.0 - releasing v4.3 as next to determine sizes

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* catch is a single handler

* v4.3 DRAFT: rename after stage to finally (#225)

* replaced after stage with finally

* fixed potential bug in createResponse to prevent pollution if passed a Request as second param, like in v4.3 stages

* createResponse simplified and safe against using as ResponseHandler

* staging changes for cors revamp

* modifying cors

* ready to begin test suite for cors

* starting basic framework for testsg

* cors complete?

* additional preflight test

* can handle multiple cookies in corsified responses

* merge conflicts in example file

* lint passing

* last merge conflict?

* credentials: true should reflect with * origin

* released v4.3.0-next.1 - releasing as next for bundle size comparison

* released v4.3.0-next.2 - next version bump

* save a couple bytes by using allowMethods default of string

* released v4.3.0-next.3 - byte shaving
  • Loading branch information
kwhitley authored Mar 25, 2024
1 parent 74f866e commit 8357b10
Show file tree
Hide file tree
Showing 15 changed files with 566 additions and 451 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"linebreak-style": ["error", "unix"],
"prefer-const": "off",
"quotes": ["error", "single", { "allowTemplateLiterals": true }],
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# OS ignores
.DS_Store
.vscode

# Logs
logs
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@ An ultra-tiny API microrouter, for use when [size matters](https://github.com/Ti
```js
import { AutoRouter } from 'itty-router' // ~1kB

export default AutoRouter()
const router = AutoRouter()

router
.get('/hello/:name', ({ name }) => `Hello, ${name}!`)
.get('/json', () => [1,2,3])
.get('/promises', () => Promise.resolve('foo'))

export default router

// that's it ^-^
```

Expand All @@ -82,6 +86,9 @@ Join us on [Discord](https://discord.gg/53vyrZAu9u)!

These folks are the real heroes, making open source the powerhouse that it is! Help out and get your name added to this list! <3

#### Constant Feedback, Suggestions, Moral Support & Community Building
- TBD

#### Core Concepts

- [@mvasigh](https://github.com/mvasigh) - proxy hack wizard behind itty, coding partner in crime, maker of the entire doc site, etc, etc.
Expand Down
4 changes: 2 additions & 2 deletions test/index.ts → lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { expect, it, vi } from 'vitest'
// generates a request from a string like:
// GET /whatever
// /foo
export const toReq = (methodAndPath: string) => {
export const toReq = (methodAndPath: string, options: RequestInit = {}) => {
let [method, path] = methodAndPath.split(' ')
if (!path) {
path = method
method = 'GET'
}

return new Request(`https://example.com${path}`, { method })
return new Request(`https://example.com${path}`, { method, ...options })
}

export const extract = ({ params, query }) => ({ params, query })
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "itty-router",
"version": "4.3.0-next.0",
"version": "4.3.0-next.3",
"description": "A tiny, zero-dependency router, designed to make beautiful APIs in any environment.",
"main": "./index.js",
"module": "./index.mjs",
Expand All @@ -16,10 +16,10 @@
"require": "./AutoRouter.js",
"types": "./AutoRouter.d.ts"
},
"./createCors": {
"import": "./createCors.mjs",
"require": "./createCors.js",
"types": "./createCors.d.ts"
"./cors": {
"import": "./cors.mjs",
"require": "./cors.js",
"types": "./cors.d.ts"
},
"./createResponse": {
"import": "./createResponse.mjs",
Expand Down
2 changes: 1 addition & 1 deletion src/AutoRouter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { toReq } from '../test'
import { toReq } from '../lib'
import { AutoRouter } from './AutoRouter'
import { text } from './text'
import { error } from './error'
Expand Down
8 changes: 4 additions & 4 deletions src/Router.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { toReq } from '../test'
import { toReq } from '../lib'
import { Router } from './Router'
import { json } from './json'
import { error } from './error'
Expand Down Expand Up @@ -115,11 +115,11 @@ describe(`SPECIFIC TESTS: Router`, () => {
})

// manipulate
router.finally.push(() => true)
router.finally?.push(() => true)

const response = await router.fetch(toReq('/'))
expect(router.before.length).toBe(2)
expect(router.finally.length).toBe(3)
expect(router.before?.length).toBe(2)
expect(router.finally?.length).toBe(3)
expect(response).toBe(true)
})

Expand Down
2 changes: 1 addition & 1 deletion src/SharedRouter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createTestRunner, extract, toReq } from '../test'
import { createTestRunner, extract, toReq } from '../lib'
import { IttyRouter } from './IttyRouter'
import { Router as FlowRouter } from './Router'

Expand Down
229 changes: 229 additions & 0 deletions src/cors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { describe, expect, it } from 'vitest'
import { toReq } from '../lib'
import { Router } from './Router'
import { CorsOptions, cors } from './cors'
import { text } from './text'

// outputs a router with a single route at index
const corsRouter = (options?: CorsOptions) => {
const { preflight, corsify } = cors(options)

return Router({
before: [preflight],
finally: [text, corsify],
}).get('/', () => TEST_STRING)
}

const DEFAULT_ROUTER = corsRouter()
const HEADERS_AS_ARRAY = [ 'x-foo', 'x-bar' ]
const HEADERS_AS_STRING = HEADERS_AS_ARRAY.join(',')
const TEST_STRING = 'Hello World'
const TEST_ORIGIN = 'https://foo.bar'
const REGEXP_DENY_ORIGIN = /^https:\/\/google.com$/
const BASIC_OPTIONS_REQUEST = toReq('OPTIONS /', {
headers: { origin: TEST_ORIGIN },
})
const BASIC_REQUEST = toReq('/', {
headers: { origin: TEST_ORIGIN },
})

describe('cors(options?: CorsOptions)', () => {
describe('BEHAVIOR', () => {
it('returns a { preflight, corsify } handler set', () => {
const { preflight, corsify } = cors()

expect(typeof preflight).toBe('function')
expect(typeof corsify).toBe('function')
})
})

describe('OPTIONS', () => {
describe('origin', () => {
it('defaults to *', async () => {
const response = await DEFAULT_ROUTER.fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
})

it('can accept a string', async () => {
const response = await corsRouter({ origin: TEST_ORIGIN }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})

it('can accept a RegExp object (if test passes, reflect origin)', async () => {
const response = await corsRouter({ origin: /oo.bar$/ }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})

it('can accept a RegExp object (undefined if fails)', async () => {
const response = await corsRouter({ origin: REGEXP_DENY_ORIGIN }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBeNull()
})

it('can accept true (reflect origin)', async () => {
const response = await corsRouter({ origin: true }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})

it('can accept a function (reflect origin if passes)', async () => {
const response = await corsRouter({ origin: () => TEST_ORIGIN.toUpperCase() }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN.toUpperCase())
})

it('can accept a function (undefined if fails)', async () => {
const response = await corsRouter({ origin: () => undefined }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBeNull()
})

it('can accept an array of strings (reflect origin if passes)', async () => {
const response = await corsRouter({ origin: [TEST_ORIGIN] }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})

it('can accept an array of strings (undefined if fails)', async () => {
const response = await corsRouter({ origin: [] }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBeNull()
})
})

describe('allowMethods', () => {
it('defaults to *', async () => {
const response = await DEFAULT_ROUTER.fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-methods')).toBe('*')
})

it('can accept a string', async () => {
const response = await corsRouter({ allowMethods: 'GET,POST' }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-methods')).toBe('GET,POST')
})

it('can accept a an array of strings', async () => {
const response = await corsRouter({ allowMethods: ['GET', 'POST'] }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-methods')).toBe('GET,POST')
})
})

describe('allowHeaders', () => {
it('defaults to undefined/null', async () => {
const response = await DEFAULT_ROUTER.fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-headers')).toBeNull()
})

it('can accept a string', async () => {
const response = await corsRouter({ allowHeaders: HEADERS_AS_STRING }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-headers')).toBe(HEADERS_AS_STRING)
})

it('can accept a an array of strings', async () => {
const response = await corsRouter({ allowHeaders: HEADERS_AS_ARRAY }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-headers')).toBe(HEADERS_AS_STRING)
})
})

describe('exposeHeaders', () => {
it('defaults to undefined/null', async () => {
const response = await DEFAULT_ROUTER.fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-expose-headers')).toBeNull()
})

it('can accept a string', async () => {
const response = await corsRouter({ exposeHeaders: HEADERS_AS_STRING }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-expose-headers')).toBe(HEADERS_AS_STRING)
})

it('can accept a an array of strings', async () => {
const response = await corsRouter({ exposeHeaders: HEADERS_AS_ARRAY }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-expose-headers')).toBe(HEADERS_AS_STRING)
})
})

describe('credentials', () => {
it('defaults to undefined/null', async () => {
const response = await DEFAULT_ROUTER.fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-credentials')).toBeNull()
})

it('can accept true', async () => {
const response = await corsRouter({ credentials: true }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
})

it('reflect domain if origin is *', async () => {
const response = await corsRouter({ credentials: true }).fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})
})
})

describe('preflight', () => {
describe('BEHAVIOR', () => {
it('responds to OPTIONS requests', async () => {
const response = await DEFAULT_ROUTER.fetch(BASIC_OPTIONS_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
})

it('ignores non-OPTIONS requests (does not return)', async () => {
const response = await DEFAULT_ROUTER.fetch(BASIC_REQUEST)
expect(response.status).toBe(200)
})

it('responds with status 204', async () => {
const response = await DEFAULT_ROUTER.fetch(BASIC_OPTIONS_REQUEST)
expect(response.status).toBe(204)
})
})
})

describe('corsify', () => {
describe('BEHAVIOR', () => {
it('adds cors headers to Response', async () => {
const { corsify } = cors()
const response = corsify(new Response(null))
expect(response.headers.get('access-control-allow-origin')).toBe('*')
expect(response.headers.get('access-control-allow-methods')).toBe('*')
})

it('will reflect origin (from request) if origin: true', async () => {
const { corsify } = cors({ origin: true })
const response = corsify(new Response(null))
const response2 = corsify(new Response(null), BASIC_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBeNull()
expect(response2.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})

it('will reflect origin (from request) if origin is in array of origins', async () => {
const { corsify } = cors({ origin: [TEST_ORIGIN] })
const response = corsify(new Response(null))
const response2 = corsify(new Response(null), BASIC_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBeNull()
expect(response2.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})

it('will reflect origin (from request) if origin passes RegExp origin test', async () => {
const { corsify } = cors({ origin: /oo.bar$/ })
const response = corsify(new Response(null))
const response2 = corsify(new Response(null), BASIC_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBeNull()
expect(response2.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})

it('will pass origin as string if given', async () => {
const { corsify } = cors({ origin: TEST_ORIGIN })
const response = corsify(new Response(null))
const response2 = corsify(new Response(null), BASIC_REQUEST)
expect(response.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
expect(response2.headers.get('access-control-allow-origin')).toBe(TEST_ORIGIN)
})

it('will safely preserve multiple cookies (or other identical header names)', async () => {
const { corsify } = cors()
const response = new Response(null)
response.headers.append('Set-Cookie', 'cookie1=value1; Path=/; HttpOnly')
response.headers.append('Set-Cookie', 'cookie2=value2; Path=/; Secure')
const corsified = corsify(response.clone())

expect(response.headers.getSetCookie().length).toBe(2)
expect(corsified.headers.getSetCookie().length).toBe(2)
})
})
})
})
Loading

0 comments on commit 8357b10

Please sign in to comment.