From 5d12e035519ee91cd9b9ab6bc8ca8e4dfc8100a7 Mon Sep 17 00:00:00 2001 From: brandon-schabel Date: Tue, 6 Aug 2024 23:34:27 -0700 Subject: [PATCH] instead of returning a response from the cors middleware, it returns headers --- server/create-cors-middleware.test.ts | 311 +++++++----------------- server/create-cors-middleware.ts | 90 ++----- server/incoming-request-handler.test.ts | 160 +++++++++--- server/incoming-request-handler.ts | 90 +++++-- server/index.ts | 2 +- server/middleware-factory.ts | 6 +- server/server-utils.ts | 14 ++ 7 files changed, 324 insertions(+), 349 deletions(-) diff --git a/server/create-cors-middleware.test.ts b/server/create-cors-middleware.test.ts index dccbfcc..f8f2422 100644 --- a/server/create-cors-middleware.test.ts +++ b/server/create-cors-middleware.test.ts @@ -1,262 +1,121 @@ -import { describe, expect, test } from 'bun:test' -import type { CORSOptions, HTTPMethod } from 'utils/http-types' +import { beforeEach, describe, expect, it, jest } from 'bun:test' +import type { CORSOptions } from '../utils/http-types' import { configCorsMiddleware } from './create-cors-middleware' -const tstOrigin = 'http://example.com' -const tstMethods: HTTPMethod[] = ['GET', 'POST', 'PUT', 'DELETE'] -const tstHeaders = ['Content-Type'] +describe('configCorsMiddleware', () => { + const mockNext = jest.fn() -const defaultOptions: CORSOptions = { - allowedOrigins: [tstOrigin], - allowedMethods: tstMethods, - allowedHeaders: tstHeaders, -} + beforeEach(() => { + mockNext.mockClear() + }) -const tstReq = ( - origin: string = tstOrigin, - options?: { - headers?: Record - Origin?: string - noOrigin?: boolean + const createMockRequest = (method: string, origin: string) => { + return { + method, + headers: new Headers({ Origin: origin }), + } as Request } -) => { - const req = (rMethod: HTTPMethod = 'GET') => { - const headers = new Headers({ - ...options?.headers, - }) - - if (options?.noOrigin) { - headers.delete('Origin') - return new Request(origin, { - method: rMethod, - headers, - }) + it('should set CORS headers for allowed origin', () => { + const options: CORSOptions = { + allowedOrigins: ['http://example.com'], + allowedMethods: ['GET', 'POST'], } + const middleware = configCorsMiddleware(options) + const req = createMockRequest('GET', 'http://example.com') - return new Request(origin, { - method: rMethod, - headers: new Headers({ - ...options?.headers, - Origin: options?.Origin ? options.Origin : origin, - }), - }) - } - return { - get: req('GET'), - post: req('POST'), - put: req('PUT'), - delete: req('DELETE'), - options: req('OPTIONS'), - } -} - -describe('createCorsMiddleware function', () => { - test('default values', async () => { - const requester = tstReq(tstOrigin).get - const headers = requester.headers - - headers.set('Access-Control-Request-Method', 'POST') - const mwConfig = await configCorsMiddleware({ - allowedOrigins: [tstOrigin], - allowedMethods: tstMethods, - allowedHeaders: ['Content-Type'], - }) + const headers = middleware(req, mockNext) - const response = await mwConfig(requester, async () => {}) - - expect(response.headers.get('access-control-allow-methods')).toBe('get, post, put, delete') - expect(response.headers.get('access-control-allow-headers')).toBe('content-type') - }) - - test('missing Origin header', async () => { - const requester = tstReq(tstOrigin, { - noOrigin: true, - }).get - const middlewareHandler = configCorsMiddleware({ - ...defaultOptions, - }) - - const response = await middlewareHandler(requester, async () => {}) - - expect(response.status).toBe(400) + expect(headers.get('Access-Control-Allow-Origin')).toBe('http://example.com') + expect(headers.get('Access-Control-Allow-Methods')).toBe('GET, POST') + expect(mockNext).toHaveBeenCalled() }) - test('options request', async () => { - const requester = tstReq(tstOrigin, { - headers: { - 'Access-Control-Request-Method': 'POST', - }, - }).options - - const response = await configCorsMiddleware(defaultOptions)(requester, async () => {}) - - expect(response.status).toBe(204) - expect(response.headers.get('access-control-allow-methods')).toBe('get, post, put, delete') - }) - - test('Allow all origins option', async () => { - const requester = tstReq(tstOrigin, { - headers: { Origin: tstOrigin }, - }).get - - const mwConfig = await configCorsMiddleware({ - allowedOrigins: ['*'], - allowedMethods: ['GET', 'PATCH'], - }) - - const response = await mwConfig(requester, async () => {}) - - expect(response.headers.get('access-control-allow-origin')).toBe('*') - }) - - test('unallowed method with options request', async () => { - const request = tstReq(tstOrigin, { - headers: { - Origin: tstOrigin, - 'Access-Control-Request-Method': 'PATCH', - }, - }).options - - const mwConfig = await configCorsMiddleware({ - allowedOrigins: [tstOrigin], + it('should handle OPTIONS request', () => { + const options: CORSOptions = { + allowedOrigins: ['http://example.com'], allowedMethods: ['GET', 'POST'], - }) + } + const middleware = configCorsMiddleware(options) + const req = createMockRequest('OPTIONS', 'http://example.com') + req.headers.set('Access-Control-Request-Method', 'GET') - const response = await mwConfig(request, async () => {}) + const response = middleware(req, mockNext) - expect(response.status).toBe(405) + expect(response).toBeInstanceOf(Response) + if (response instanceof Response) { + expect(response.status).toBe(204) + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('http://example.com') + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST') + } + expect(mockNext).not.toHaveBeenCalled() }) - test('non-options request', async () => { - const requester = new Request(tstOrigin, { - method: 'GET', - headers: new Headers({ - Origin: tstOrigin, - }), - }) - const mwConfig = await configCorsMiddleware({ + it('should throw error for disallowed origin', () => { + const options: CORSOptions = { + allowedOrigins: ['http://example.com'], allowedMethods: ['GET'], - allowedOrigins: [tstOrigin], - }) - - const response = await mwConfig(requester, async () => {}) - - expect(response?.headers.get('access-control-allow-origin')).toBe(tstOrigin.toLowerCase()) - }) - - test('should set Access-Control-Allow-Origin header to request origin', async () => { - const request = tstReq(tstOrigin, { - headers: { Origin: tstOrigin }, - }).get - - const mwConfig = await configCorsMiddleware({ - allowedOrigins: [tstOrigin], - }) - - const response = await mwConfig(request, async () => {}) - - expect(response.headers.get('access-control-allow-origin')).toBe(tstOrigin.toLowerCase()) - }) - - test('should set Access-Control-Allow-Origin header to * if allowedOrigins includes *', async () => { - const request = tstReq(tstOrigin, { - headers: { Origin: tstOrigin }, - }).get - - const mwConfig = await configCorsMiddleware({ allowedOrigins: ['*'] }) - - const response = await mwConfig(request, async () => {}) + } + const middleware = configCorsMiddleware(options) + const req = createMockRequest('GET', 'http://malicious.com') - expect(response.headers.get('access-control-allow-origin')).toBe('*') + expect(() => middleware(req, mockNext)).toThrow('Origin http://malicious.com not allowed') + expect(mockNext).not.toHaveBeenCalled() }) - test('should set Access-Control-Allow-Methods header to allowedMethods', async () => { - const request = new Request('http://example.com', { - headers: { Origin: 'http://example.com' }, - method: 'GET', - }) - - const mwConfig = await configCorsMiddleware({ - allowedMethods: ['GET', 'POST'], - allowedOrigins: [tstOrigin], - }) + it('should throw error for disallowed method', () => { + const options: CORSOptions = { + allowedOrigins: ['http://example.com'], + allowedMethods: ['GET'], + } + const middleware = configCorsMiddleware(options) + const req = createMockRequest('POST', 'http://example.com') - const response = await mwConfig(request, async () => {}) - expect(response.headers.get('access-control-allow-methods')).toBe('get, post') + expect(() => middleware(req, mockNext)).toThrow('Method POST not allowed') + expect(mockNext).not.toHaveBeenCalled() }) - test('should set Access-Control-Allow-Headers header to allowedHeaders', async () => { - const request = tstReq(tstOrigin, { - headers: { Origin: tstOrigin, 'Content-Type': 'application/json' }, - }).get - - const mwConfig = configCorsMiddleware({ - allowedHeaders: ['Content-Type'], - allowedOrigins: [tstOrigin], - }) - - const response = await mwConfig(request, async () => {}) - - expect(response.headers.get('access-control-allow-headers')).toBe('content-type') - }) + it('should handle wildcard origin', () => { + const options: CORSOptions = { + allowedOrigins: ['*'], + allowedMethods: ['GET'], + } + const middleware = configCorsMiddleware(options) + const req = createMockRequest('GET', 'http://any-origin.com') - test('should return 400 Bad Request if request does not have Origin header', async () => { - const request = tstReq(tstOrigin, { - noOrigin: true, - }).get - const mwConfig = await configCorsMiddleware({}) - const response = await mwConfig(request, async () => {}) + const headers = middleware(req, mockNext) - expect(response.status).toBe(400) + expect(headers.get('Access-Control-Allow-Origin')).toBe('*') + expect(mockNext).toHaveBeenCalled() }) - test('should return 403 Forbidden if request origin is not allowed', async () => { - const request = new Request('http://example.org', { - headers: { Origin: 'http://example.org' }, - }) - - const mwConfig = await configCorsMiddleware({ + it('should set credentials header when specified', () => { + const options: CORSOptions = { allowedOrigins: ['http://example.com'], - }) - - const response = await mwConfig(request, async () => {}) - - expect(response.status).toBe(403) - }) - - test('should return 405 Method Not Allowed if request method is not allowed', async () => { - const request = tstReq(tstOrigin, { - headers: { - 'Access-Control-Request-Method': 'POST', - }, - }).post + allowedMethods: ['GET'], + credentials: true, + } + const middleware = configCorsMiddleware(options) + const req = createMockRequest('GET', 'http://example.com') - const response = await configCorsMiddleware( - { - allowedMethods: ['GET'], - allowedOrigins: [tstOrigin], - }, - true - )(request, async () => {}) + const headers = middleware(req, mockNext) - expect(response.status).toBe(405) + expect(headers.get('Access-Control-Allow-Credentials')).toBe('true') + expect(mockNext).toHaveBeenCalled() }) - test('should return 204 No Content if request method is options and allowed', async () => { - const request = tstReq(tstOrigin, { - headers: { - 'Access-Control-Request-Method': 'GET', - }, - }).options - - const mwConfig = await configCorsMiddleware({ + it('should set allowed headers when specified', () => { + const options: CORSOptions = { + allowedOrigins: ['http://example.com'], allowedMethods: ['GET'], - allowedOrigins: [tstOrigin], - }) + allowedHeaders: ['Content-Type', 'Authorization'], + } + const middleware = configCorsMiddleware(options) + const req = createMockRequest('GET', 'http://example.com') - const response = await mwConfig(request, async () => {}) + const headers = middleware(req, mockNext) - expect(response.status).toBe(204) + expect(headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, Authorization') + expect(mockNext).toHaveBeenCalled() }) -}) +}) \ No newline at end of file diff --git a/server/create-cors-middleware.ts b/server/create-cors-middleware.ts index 4df56af..2103626 100644 --- a/server/create-cors-middleware.ts +++ b/server/create-cors-middleware.ts @@ -2,101 +2,65 @@ import type { CORSOptions } from '../utils/http-types' import type { Middleware, NextFunction } from './middleware-types' const setAllowOrigin = (headers: Headers, originToSet: string) => - headers.set('access-control-allow-origin', originToSet?.toLowerCase() || '') + headers.set('Access-Control-Allow-Origin', originToSet || '') const setAllowMethods = (headers: Headers, methods: string[]) => - headers.set('access-control-allow-methods', methods.map((m) => m.toLowerCase()).join(', ')) + headers.set('Access-Control-Allow-Methods', methods.map((method) => method).join(', ')) const addAllowHeader = (headers: Headers, options?: CORSOptions) => { if (options?.allowedHeaders?.join) { - headers.set('access-control-allow-headers', options.allowedHeaders.map((h) => h.toLowerCase()).join(', ')) + headers.set('Access-Control-Allow-Headers', options.allowedHeaders.map((header) => header).join(', ')) } } const setAllowCredentials = (headers: Headers, options?: CORSOptions) => - options?.credentials && headers.set('access-control-allow-credentials', 'true') + options?.credentials && headers.set('Access-Control-Allow-Credentials', 'true') -export const configCorsMiddleware = (options?: CORSOptions, debug = false): Middleware => { +export const configCorsMiddleware = (options?: CORSOptions): Middleware => { const allowedMethods: string[] = options?.allowedMethods || [] - const log = (input: any) => { - if (debug) { - console.log(input) - } - } - - const sendErrorResponse = (status: number, statusText: string, detail: string, headers?: Headers) => { - const errorResponse = { statusText, detail } - if (debug) { - console.error(errorResponse) - } - - const finalHeaders = headers - - finalHeaders?.set('Content-Type', 'application/json') - - return new Response(JSON.stringify(errorResponse), { - status, - headers: finalHeaders, - }) - } - - return async (request: Request, next: NextFunction) => { + return (request: Request, next: NextFunction) => { const reqMethod = request.method.toUpperCase() - const reqOrigin = request.headers.get('Origin')?.toLowerCase() - const allowedOrigins = options?.allowedOrigins?.map((origin) => origin.toLowerCase()) || [] - const originAllowed = allowedOrigins.includes(reqOrigin || '') - - if (debug && !originAllowed) { - log({ - allowedOrigins, - reqOrigin, - originAllowed, - }) - } + const reqOrigin = request.headers.get('Origin') - const headers = new Headers() + const allowedOrigins = options?.allowedOrigins || [] + const originAllowed = allowedOrigins.includes('*') || allowedOrigins.includes(reqOrigin || '') + const methodAllowed = allowedMethods.includes(reqMethod) + + const corsHeaders = new Headers() const originToSet = allowedOrigins.includes('*') ? '*' : reqOrigin if (!reqOrigin) { - return sendErrorResponse(400, 'Bad Request', 'Origin header missing') + throw new Error('Origin header missing') } // Set CORS headers - setAllowOrigin(headers, originToSet || '') - setAllowMethods(headers, allowedMethods) - addAllowHeader(headers, options) - setAllowCredentials(headers, options) + setAllowOrigin(corsHeaders, originToSet || '') + setAllowMethods(corsHeaders, allowedMethods) + addAllowHeader(corsHeaders, options) + setAllowCredentials(corsHeaders, options) if (reqMethod === 'OPTIONS') { const optionRequestMethod = request.headers.get('Access-Control-Request-Method') - // Check if the requested method is allowed, but only if it's specified if (optionRequestMethod && !allowedMethods.includes(optionRequestMethod)) { - return sendErrorResponse(405, 'Method Not Allowed', `Method ${optionRequestMethod} is not allowed`, headers) + throw new Error(`Method ${optionRequestMethod} is not allowed`) } - // If it's an OPTIONS request and the method is allowed (or not specified), return 204 No Content - return new Response(null, { status: 204, headers }) + // For OPTIONS requests, we'll return early with a 204 response + return new Response(null, { status: 204, headers: corsHeaders }) } - // For non-OPTIONS requests - await next() - - // Apply CORS headers to the response after next() has been called - const response = new Response(null, { status: 200 }) - headers.forEach((value, key) => { - response.headers.set(key, value) - }) - - if (!allowedOrigins.includes(reqOrigin || '')) { - return sendErrorResponse(403, 'Forbidden', `Origin ${reqOrigin} not allowed`, response.headers) + if (!originAllowed) { + throw new Error(`Origin ${reqOrigin} not allowed`) } - if (!allowedMethods.includes(reqMethod)) { - return sendErrorResponse(405, 'Method Not Allowed', `Method ${reqMethod} not allowed`, response.headers) + if (!methodAllowed) { + throw new Error(`Method ${reqMethod} not allowed`) } - return response + // Continue to the next middleware or route handler, passing the CORS headers + next() + return corsHeaders } } diff --git a/server/incoming-request-handler.test.ts b/server/incoming-request-handler.test.ts index f823c99..e25fc1e 100644 --- a/server/incoming-request-handler.test.ts +++ b/server/incoming-request-handler.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, it, jest, test } from 'bun:test' import { serverRequestHandler } from './incoming-request-handler' import { middlewareFactory } from './middleware-factory' +import type { Routes } from './route-types' describe('serverRequestHandler', () => { - const mockRoutes = { + const mockRoutes: Routes = { '/test': { get: jest.fn().mockResolvedValue(new Response('Test GET')), post: jest.fn().mockResolvedValue(new Response('Test POST')), @@ -13,31 +14,44 @@ describe('serverRequestHandler', () => { }, } - const mockMiddlewareFactory = middlewareFactory({}) - const mockExecuteMiddlewares = jest.fn().mockResolvedValue({}) + const mockMiddlewareFactory = middlewareFactory({ + test: () => ({ test: 'data' }), + }) + + const mockOptionsHandler = jest.fn().mockResolvedValue(new Response('Options')) beforeEach(() => { jest.clearAllMocks() - mockMiddlewareFactory.executeMiddlewares = mockExecuteMiddlewares }) - it('handles exact path match', async () => { + it('should handle a simple GET request', async () => { const req = new Request('http://example.com/test', { method: 'GET' }) const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) - expect(mockRoutes['/test'].get).toHaveBeenCalled() + expect(response.status).toBe(200) expect(await response.text()).toBe('Test GET') + expect(mockRoutes['/test'].get).toHaveBeenCalled() }) - it('handles regex path match', async () => { + it('should handle a POST request', async () => { + const req = new Request('http://example.com/test', { method: 'POST' }) + const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('Test POST') + expect(mockRoutes['/test'].post).toHaveBeenCalled() + }) + + it('should handle a regex route', async () => { const req = new Request('http://example.com/regex/123', { method: 'GET' }) const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) - expect(mockRoutes['/regex/\\d+'].get).toHaveBeenCalled() + expect(response.status).toBe(200) expect(await response.text()).toBe('Regex GET') + expect(mockRoutes['/regex/\\d+'].get).toHaveBeenCalled() }) - it('returns 404 for unmatched path', async () => { + it('should return 404 for unmatched routes', async () => { const req = new Request('http://example.com/nonexistent', { method: 'GET' }) const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) @@ -45,38 +59,122 @@ describe('serverRequestHandler', () => { expect(await response.text()).toBe('Not Found') }) - it('returns 404 for unmatched method', async () => { - const req = new Request('http://example.com/test', { method: 'PUT' }) - const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) + it('should handle OPTIONS requests', async () => { + const req = new Request('http://example.com/test', { method: 'OPTIONS' }) + const response = await serverRequestHandler({ + req, + routes: mockRoutes, + middlewareRet: mockMiddlewareFactory, + optionsHandler: mockOptionsHandler, + }) - expect(response.status).toBe(404) - expect(await response.text()).toBe('Not Found') + expect(response.status).toBe(200) + expect(await response.text()).toBe('Options') + expect(mockOptionsHandler).toHaveBeenCalled() }) - it('executes middleware before route handler', async () => { + it('should return default OPTIONS response if no optionsHandler provided', async () => { + const req = new Request('http://example.com/test', { method: 'OPTIONS' }) + const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) + + expect(response.status).toBe(204) + }) + it('should handle middleware responses', async () => { + const mockMiddleware = middlewareFactory({ + test: () => new Response('Middleware Response'), + }) const req = new Request('http://example.com/test', { method: 'GET' }) - await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) + const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddleware }) + expect(response.status).toBe(200) + const responseText = await response.text() + expect(responseText).toBe('Middleware Response') + // Ensure that the route handler wasn't called + expect(mockRoutes['/test'].get).not.toHaveBeenCalled() + }) + it('should handle errors and return 500 status', async () => { + const errorRoutes: Routes = { + '/error': { + get: jest.fn().mockRejectedValue(new Error('Test Error')), + }, + } + const req = new Request('http://example.com/error', { method: 'GET' }) + const response = await serverRequestHandler({ req, routes: errorRoutes, middlewareRet: mockMiddlewareFactory }) + + expect(response.status).toBe(500) + expect(await response.text()).toBe('Test Error') + }) + it('should handle nested regex routes', async () => { + const nestedRegexRoutes: Routes = { + '/api/users/\\d+/posts/\\d+': { + get: jest.fn().mockResolvedValue(new Response('Nested Regex GET')), + }, + } + const req = new Request('http://example.com/api/users/123/posts/456', { method: 'GET' }) + const response = await serverRequestHandler({ + req, + routes: nestedRegexRoutes, + middlewareRet: mockMiddlewareFactory, + }) - expect(mockExecuteMiddlewares).toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.text()).toBe('Nested Regex GET') + expect(nestedRegexRoutes['/api/users/\\d+/posts/\\d+'].get).toHaveBeenCalled() + }) + + it('should handle URLs with query parameters', async () => { + const req = new Request('http://example.com/test?param1=value1¶m2=value2', { method: 'GET' }) + const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) + + console.log('Response:', { + status: response.status, + headers: response.headers.toJSON(), + bodyUsed: response.bodyUsed, + }) + + expect(response.status).toBe(200) + + if (!response.bodyUsed) { + const text = await response.text() + expect(text).toBe('Test GET') + } else { + console.warn('Response body already used') + } + expect(mockRoutes['/test'].get).toHaveBeenCalled() + }) - // Check the order of calls - const mockCalls = mockExecuteMiddlewares.mock.invocationCallOrder[0] - const routeHandlerCalls = mockRoutes['/test'].get.mock.invocationCallOrder[0] - expect(mockCalls).toBeLessThan(routeHandlerCalls) + it('should return 405 Method Not Allowed for unsupported methods', async () => { + const req = new Request('http://example.com/test', { method: 'PUT' }) + const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: mockMiddlewareFactory }) + + expect(response.status).toBe(405) + expect(await response.text()).toBe('Method Not Allowed') }) - it('handles OPTIONS request with optionsHandler', async () => { - const mockOptionsHandler = jest.fn().mockResolvedValue(new Response('OPTIONS')) - const req = new Request('http://example.com/test', { method: 'OPTIONS' }) - const response = await serverRequestHandler({ - req, - routes: mockRoutes, - middlewareRet: mockMiddlewareFactory, - optionsHandler: mockOptionsHandler, + it('should handle errors in middleware', async () => { + const errorMiddleware = middlewareFactory({ + error: () => { + throw new Error('Middleware Error') + }, }) + const req = new Request('http://example.com/test', { method: 'GET' }) + const response = await serverRequestHandler({ req, routes: mockRoutes, middlewareRet: errorMiddleware }) - expect(mockOptionsHandler).toHaveBeenCalled() - expect(await response.text()).toBe('OPTIONS') + expect(response.status).toBe(500) + expect(await response.text()).toBe('Middleware Error') + }) + + it('should handle URLs with Unicode characters', async () => { + const unicodeRoutes: Routes = { + '/test/üñîçødé': { + get: jest.fn().mockResolvedValue(new Response('Unicode GET')), + }, + } + const req = new Request('http://example.com/test/üñîçødé', { method: 'GET' }) + const response = await serverRequestHandler({ req, routes: unicodeRoutes, middlewareRet: mockMiddlewareFactory }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('Unicode GET') + expect(unicodeRoutes['/test/üñîçødé'].get).toHaveBeenCalled() }) }) diff --git a/server/incoming-request-handler.ts b/server/incoming-request-handler.ts index df9abd5..de0c506 100644 --- a/server/incoming-request-handler.ts +++ b/server/incoming-request-handler.ts @@ -29,43 +29,81 @@ export const serverRequestHandler = < optionsHandler?: RouteHandler }): Promise => { const url = new URL(req.url) - let matchedHandler: RouteHandler | null | undefined = null + const decodedPathname = decodeURIComponent(url.pathname) + const executeMiddlewares = middlewareRet?.executeMiddlewares - const pathRoutes = routes[url.pathname] + // Execute middleware first + const middlewarePromise = executeMiddlewares ? executeMiddlewares(req) : Promise.resolve({} as MiddlewareDataMap) - matchedHandler = pathRoutes ? pathRoutes[req.method.toLowerCase() as keyof typeof pathRoutes] : null + console.log({ + req, + middlewarePromise, + }) - // try regex match after direct string match - if (!matchedHandler) { - for (const pattern in routes) { - if (isValidRegex(pattern)) { - const regex = new RegExp(pattern, 'i') - if (regex.test(url.pathname)) { - matchedHandler = routes[pattern][req.method.toLowerCase() as keyof (typeof routes)[typeof pattern]] - break + return middlewarePromise + .then(async (middlewareResponses) => { + // Check if middleware has already handled the response + if (middlewareResponses && typeof middlewareResponses === 'object') { + for (const key in middlewareResponses) { + if (middlewareResponses[key] instanceof Response) { + return middlewareResponses[key] as Response + } } } - } - } - if (!matchedHandler && !optionsHandler) return Promise.resolve(new Response('Not Found', { status: 404 })) - const executeMiddlewares = middlewareRet?.executeMiddlewares + let matchedHandler: RouteHandler | null | undefined = null + + const pathRoutes = routes[decodedPathname] + if (pathRoutes) { + matchedHandler = pathRoutes[req.method.toLowerCase() as keyof typeof pathRoutes] + if (!matchedHandler && req.method !== 'OPTIONS') { + // Method not allowed for this path + return new Response('Method Not Allowed', { status: 405 }) + } + } - // Ensure that middleware execution is properly handled when it's not provided - const middlewareResponses = executeMiddlewares - ? executeMiddlewares(req) - : Promise.resolve({} as MiddlewareDataMap) + // try regex match after direct string match + if (!matchedHandler) { + for (const pattern in routes) { + if (isValidRegex(pattern)) { + const regex = new RegExp(pattern, 'i') + if (regex.test(decodedPathname)) { + matchedHandler = routes[pattern][req.method.toLowerCase() as keyof (typeof routes)[typeof pattern]] + break + } + } + } + } - return middlewareResponses - .then((resolvedMwResponses) => { - if (req.method === 'OPTIONS' && !matchedHandler && optionsHandler) { - return optionsHandler(req, resolvedMwResponses as MiddlewareDataMap) + if (req.method === 'OPTIONS') { + return optionsHandler + ? optionsHandler(req, middlewareResponses as MiddlewareDataMap) + : new Response(null, { status: 204 }) // Default OPTIONS response } - return matchedHandler - ? matchedHandler(req, resolvedMwResponses as MiddlewareDataMap) - : new Response('Method Not Allowed', { status: 405 }) + if (!matchedHandler) { + console.error('No match found for request', { + url: req.url, + method: req.method, + pathRoutes, + routes, + }) + return new Response('Not Found', { status: 404 }) + } + + const response = await matchedHandler(req, middlewareResponses as MiddlewareDataMap) + + const corsHeaders = middlewareResponses.cors as Headers | undefined + if (corsHeaders) { + corsHeaders.forEach((value, key) => { + response.headers.set(key, value) + }) + } + + + + return response }) .catch((err) => new Response(err.message, { status: 500 })) } diff --git a/server/index.ts b/server/index.ts index b125f4b..7dc47bb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,5 +3,5 @@ export type { InferMiddlewareFromFactory } from './middleware-factory' export { serverFactory } from './server-factory' export type { InferMiddlewareDataMap, Middleware, MiddlewareConfigMap } from './middleware-types' export type { RouteHandler, RouteOptionsMiddlewareManger, Routes } from './route-types' -export { htmlRes, jsonRes, redirectRes } from './server-utils' +export { htmlRes, jsonRes, redirectRes, combineResponseHeaders } from './server-utils' export { configCorsMiddleware } from './create-cors-middleware' diff --git a/server/middleware-factory.ts b/server/middleware-factory.ts index 848bfdb..6d72c5e 100644 --- a/server/middleware-factory.ts +++ b/server/middleware-factory.ts @@ -26,7 +26,9 @@ export const middlewareFactory = (middlewareOptio } const result = await mw(req, next) - results[id as keyof T] = result + if (result !== undefined) { + results[id as keyof T] = result + } } await executeMiddleware(0) @@ -39,4 +41,4 @@ export const middlewareFactory = (middlewareOptio } return { executeMiddlewares, inferTypes } -} +} \ No newline at end of file diff --git a/server/server-utils.ts b/server/server-utils.ts index e17e231..bacc8c6 100644 --- a/server/server-utils.ts +++ b/server/server-utils.ts @@ -83,3 +83,17 @@ export const redirectRes = (url: string, options: RedirectOptions = {}): Respons headers, }) } + +export function combineResponseHeaders(...responses: Response[] | [Response, Response]): Headers { + const combinedHeaders = new Headers() + + const responsesToCombine: Response[] = Array.isArray(responses[0]) ? responses[0] : responses + + for (const response of responsesToCombine) { + response.headers.forEach((value, key) => { + combinedHeaders.set(key, value) + }) + } + + return combinedHeaders +}