diff --git a/src/routes/root.ts b/src/routes/root.ts index 2038a0d..6209f8e 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -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' @@ -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({ 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) diff --git a/test/cors.test.ts b/test/cors.test.ts new file mode 100644 index 0000000..5098fed --- /dev/null +++ b/test/cors.test.ts @@ -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('*') + }) +})