Skip to content

Commit 0f4c03e

Browse files
authored
feat: Add cors headers (#20)
* feat: Add cors headers * fix abort test failures due to Response.clone()
1 parent 26cae8c commit 0f4c03e

2 files changed

Lines changed: 123 additions & 2 deletions

File tree

src/routes/root.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { json, Router, type IRequestStrict } from 'itty-router'
1+
import { cors, json, Router, type IRequestStrict } from 'itty-router'
22

33
import type { Context } from '../context.js'
44
import { error } from '../lib/errors.js'
@@ -12,15 +12,45 @@ const MAPS_BASE = '/maps/'
1212
const MAP_SHARES_BASE = '/mapShares/'
1313
const DOWNLOADS_BASE = '/downloads/'
1414

15+
const { preflight } = cors({
16+
origin: '*',
17+
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
18+
allowHeaders: ['Content-Type'],
19+
})
20+
21+
/**
22+
* Custom corsify that doesn't clone the response body.
23+
* The built-in itty-router corsify uses response.clone() which breaks
24+
* streaming response error handling (abort signals don't propagate correctly).
25+
*/
26+
function corsify(response: Response): Response {
27+
// Skip if CORS headers already present or if it's a WebSocket upgrade
28+
if (response.headers.get('access-control-allow-origin') || response.status === 101) {
29+
return response
30+
}
31+
// Create new headers with CORS header added
32+
const headers = new Headers(response.headers)
33+
headers.set('access-control-allow-origin', '*')
34+
// Return new Response with same body (not cloned) and updated headers
35+
return new Response(response.body, {
36+
status: response.status,
37+
statusText: response.statusText,
38+
headers,
39+
})
40+
}
41+
1542
export function RootRouter({ base = '/' }, ctx: Context): RouterExternal {
1643
const router = Router<IRequestStrict, [FetchContext]>({
1744
base,
45+
// Handle CORS preflight OPTIONS requests
46+
before: [preflight],
1847
// The `error` handler will send a response with the status code from any
1948
// thrown StatusError, or a 500 for any other errors.
2049
catch: (err) => error(err),
2150
// Sends a 404 response for any requests that don't match a route, and for
2251
// any request handlers that return JSON will send a JSON response.
23-
finally: [(response) => response ?? error(404), json],
52+
// corsify adds CORS headers to all responses.
53+
finally: [(response) => response ?? error(404), json, corsify],
2454
})
2555

2656
const mapsRouter = MapsRouter({ base: MAPS_BASE }, ctx)

test/cors.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { startServer } from './helpers.js'
4+
5+
describe('CORS Headers', () => {
6+
it('should include CORS headers on GET requests', async (t) => {
7+
const { localBaseUrl } = await startServer(t)
8+
const response = await fetch(`${localBaseUrl}/maps/custom/style.json`)
9+
expect(response.status).toBe(200)
10+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
11+
})
12+
13+
it('should include CORS headers on 404 responses', async (t) => {
14+
const { localBaseUrl } = await startServer(t)
15+
const response = await fetch(`${localBaseUrl}/unknown-route`)
16+
expect(response.status).toBe(404)
17+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
18+
})
19+
20+
it('should include CORS headers on error responses', async (t) => {
21+
const { localBaseUrl } = await startServer(t)
22+
const response = await fetch(`${localBaseUrl}/maps/custom`, {
23+
method: 'PUT',
24+
body: Buffer.from('invalid'),
25+
headers: { 'Content-Type': 'application/octet-stream' },
26+
})
27+
expect(response.status).toBe(400)
28+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
29+
})
30+
31+
it('should handle OPTIONS preflight requests', async (t) => {
32+
const { localBaseUrl } = await startServer(t)
33+
const response = await fetch(`${localBaseUrl}/maps/custom/style.json`, {
34+
method: 'OPTIONS',
35+
})
36+
expect(response.status).toBe(204)
37+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
38+
expect(response.headers.get('access-control-allow-methods')).toBe(
39+
'GET,POST,DELETE,OPTIONS',
40+
)
41+
expect(response.headers.get('access-control-allow-headers')).toBe(
42+
'Content-Type',
43+
)
44+
})
45+
46+
it('should handle OPTIONS preflight for POST endpoints', async (t) => {
47+
const { localBaseUrl } = await startServer(t)
48+
const response = await fetch(`${localBaseUrl}/mapShares`, {
49+
method: 'OPTIONS',
50+
})
51+
expect(response.status).toBe(204)
52+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
53+
expect(response.headers.get('access-control-allow-methods')).toBe(
54+
'GET,POST,DELETE,OPTIONS',
55+
)
56+
})
57+
58+
it('should handle OPTIONS preflight for DELETE endpoints', async (t) => {
59+
const { localBaseUrl } = await startServer(t)
60+
const response = await fetch(`${localBaseUrl}/maps/custom`, {
61+
method: 'OPTIONS',
62+
})
63+
expect(response.status).toBe(204)
64+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
65+
expect(response.headers.get('access-control-allow-methods')).toBe(
66+
'GET,POST,DELETE,OPTIONS',
67+
)
68+
})
69+
70+
it('should include CORS headers on POST responses', async (t) => {
71+
const { localBaseUrl } = await startServer(t)
72+
// This will fail validation, but should still have CORS headers
73+
const response = await fetch(`${localBaseUrl}/mapShares`, {
74+
method: 'POST',
75+
headers: { 'Content-Type': 'application/json' },
76+
body: JSON.stringify({}),
77+
})
78+
// 400 due to missing required fields
79+
expect(response.status).toBe(400)
80+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
81+
})
82+
83+
it('should include CORS headers on DELETE responses', async (t) => {
84+
const { localBaseUrl } = await startServer(t)
85+
const response = await fetch(`${localBaseUrl}/maps/custom`, {
86+
method: 'DELETE',
87+
})
88+
expect(response.status).toBe(204)
89+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
90+
})
91+
})

0 commit comments

Comments
 (0)