Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions src/routes/root.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { json, Router, type IRequestStrict } from 'itty-router'
import { cors, json, Router, type IRequestStrict } from 'itty-router'

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

const { preflight } = cors({
origin: '*',
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type'],
})

/**
* Custom corsify that doesn't clone the response body.
* The built-in itty-router corsify uses response.clone() which breaks
* streaming response error handling (abort signals don't propagate correctly).
*/
function corsify(response: Response): Response {
// Skip if CORS headers already present or if it's a WebSocket upgrade
if (response.headers.get('access-control-allow-origin') || response.status === 101) {
return response
}
// Create new headers with CORS header added
const headers = new Headers(response.headers)
headers.set('access-control-allow-origin', '*')
// Return new Response with same body (not cloned) and updated headers
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}

export function RootRouter({ base = '/' }, ctx: Context): RouterExternal {
const router = Router<IRequestStrict, [FetchContext]>({
base,
// Handle CORS preflight OPTIONS requests
before: [preflight],
// The `error` handler will send a response with the status code from any
// thrown StatusError, or a 500 for any other errors.
catch: (err) => error(err),
// Sends a 404 response for any requests that don't match a route, and for
// any request handlers that return JSON will send a JSON response.
finally: [(response) => response ?? error(404), json],
// corsify adds CORS headers to all responses.
finally: [(response) => response ?? error(404), json, corsify],
})

const mapsRouter = MapsRouter({ base: MAPS_BASE }, ctx)
Expand Down
91 changes: 91 additions & 0 deletions test/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest'

import { startServer } from './helpers.js'

describe('CORS Headers', () => {
it('should include CORS headers on GET requests', async (t) => {
const { localBaseUrl } = await startServer(t)
const response = await fetch(`${localBaseUrl}/maps/custom/style.json`)
expect(response.status).toBe(200)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
})

it('should include CORS headers on 404 responses', async (t) => {
const { localBaseUrl } = await startServer(t)
const response = await fetch(`${localBaseUrl}/unknown-route`)
expect(response.status).toBe(404)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
})

it('should include CORS headers on error responses', async (t) => {
const { localBaseUrl } = await startServer(t)
const response = await fetch(`${localBaseUrl}/maps/custom`, {
method: 'PUT',
body: Buffer.from('invalid'),
headers: { 'Content-Type': 'application/octet-stream' },
})
expect(response.status).toBe(400)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
})

it('should handle OPTIONS preflight requests', async (t) => {
const { localBaseUrl } = await startServer(t)
const response = await fetch(`${localBaseUrl}/maps/custom/style.json`, {
method: 'OPTIONS',
})
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
expect(response.headers.get('access-control-allow-methods')).toBe(
'GET,POST,DELETE,OPTIONS',
)
expect(response.headers.get('access-control-allow-headers')).toBe(
'Content-Type',
)
})

it('should handle OPTIONS preflight for POST endpoints', async (t) => {
const { localBaseUrl } = await startServer(t)
const response = await fetch(`${localBaseUrl}/mapShares`, {
method: 'OPTIONS',
})
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
expect(response.headers.get('access-control-allow-methods')).toBe(
'GET,POST,DELETE,OPTIONS',
)
})

it('should handle OPTIONS preflight for DELETE endpoints', async (t) => {
const { localBaseUrl } = await startServer(t)
const response = await fetch(`${localBaseUrl}/maps/custom`, {
method: 'OPTIONS',
})
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
expect(response.headers.get('access-control-allow-methods')).toBe(
'GET,POST,DELETE,OPTIONS',
)
})

it('should include CORS headers on POST responses', async (t) => {
const { localBaseUrl } = await startServer(t)
// This will fail validation, but should still have CORS headers
const response = await fetch(`${localBaseUrl}/mapShares`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
// 400 due to missing required fields
expect(response.status).toBe(400)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
})

it('should include CORS headers on DELETE responses', async (t) => {
const { localBaseUrl } = await startServer(t)
const response = await fetch(`${localBaseUrl}/maps/custom`, {
method: 'DELETE',
})
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
})
})
Loading