diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index a304f33dfd3..b53351a5249 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -380,12 +380,32 @@ async function handleServerRoutes({ createHandlers: (d: any) => d, }) : server.handlers + const normalizedHandlers: Record = {} + for (const [k, v] of Object.entries(handlers)) { + normalizedHandlers[k.toUpperCase()] = v + } - const requestMethod = request.method.toUpperCase() as RouteMethod - + let requestMethod = request.method.toUpperCase() as RouteMethod + if (requestMethod === 'HEAD' && normalizedHandlers['GET']) { + requestMethod = 'GET' as RouteMethod + } + const hasAny = !!normalizedHandlers['ANY'] // Attempt to find the method in the handlers - const handler = handlers[requestMethod] ?? handlers['ANY'] + const handler = + normalizedHandlers[requestMethod] ?? normalizedHandlers['ANY'] + if (!handler && !hasAny) { + if (request.method.toUpperCase() === 'HEAD') { + return new Response(null, { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify({ error: 'Not Found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } // If a method is found, execute the handler if (handler) { const mayDefer = !!foundRoute.options.component diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts new file mode 100644 index 00000000000..adb0ec9484c --- /dev/null +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createStartHandler } from '../src' +import { currentHandlers } from './mocks/router-entry' + +const spaFallback = async () => + new Response('
spa
', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }) + +function makeApp() { + return createStartHandler(async () => await spaFallback()) +} +beforeEach(() => { + Object.keys(currentHandlers).forEach((key) => delete currentHandlers[key]) +}) + +describe('createStartHandler — server route HTTP method handling', function () { + it('should return 404 JSON for GET when only POST is defined (no SPA fallback)', async function () { + currentHandlers.POST = () => new Response('ok', { status: 200 }) + const app = makeApp() + + const res = await app( + new Request('http://localhost/api/test-no-get', { method: 'GET' }), + ) + + expect(res.status).toBe(404) + expect(res.headers.get('content-type')).toMatch(/application\/json/i) + const txt = await res.text() + expect(txt).toContain('Not Found') + expect(txt.toLowerCase().startsWith('')).toBe(false) + }) + + it('should return 200 for POST and execute the route handler', async function () { + currentHandlers.POST = () => new Response('ok', { status: 200 }) + const app = makeApp() + + const res = await app( + new Request('http://localhost/api/test-no-get', { method: 'POST' }), + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('ok') + }) + + it('should return 404 for HEAD when GET is not defined', async function () { + currentHandlers.POST = () => new Response('ok', { status: 200 }) + const app = makeApp() + + const res = await app( + new Request('http://localhost/api/test-no-get', { method: 'HEAD' }), + ) + + expect(res.status).toBe(404) + }) + + it('should use GET handler when HEAD is requested and GET exists', async function () { + currentHandlers.GET = () => new Response('hello', { status: 200 }) + const app = makeApp() + + const res = await app( + new Request('http://localhost/api/has-get', { method: 'HEAD' }), + ) + + expect(res.status).toBe(200) + }) + + it('should execute ANY handler for unsupported methods (e.g., PUT)', async function () { + currentHandlers.ANY = () => new Response('ok-any', { status: 200 }) + const app = makeApp() + + const res = await app( + new Request('http://localhost/api/any', { method: 'PUT' }), + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('ok-any') + }) +}) diff --git a/packages/start-server-core/tests/mocks/injected-head-scripts.ts b/packages/start-server-core/tests/mocks/injected-head-scripts.ts new file mode 100644 index 00000000000..9e98c1c1672 --- /dev/null +++ b/packages/start-server-core/tests/mocks/injected-head-scripts.ts @@ -0,0 +1 @@ +export const injectedHeadScripts = '' diff --git a/packages/start-server-core/tests/mocks/router-entry.ts b/packages/start-server-core/tests/mocks/router-entry.ts new file mode 100644 index 00000000000..98a7a99254e --- /dev/null +++ b/packages/start-server-core/tests/mocks/router-entry.ts @@ -0,0 +1,30 @@ +import type { AnyRouter } from '@tanstack/router-core' + +export const currentHandlers: Record = {} + +function makeFakeRouter(): AnyRouter { + return { + rewrite: undefined as any, + getMatchedRoutes: (_pathname: string) => ({ + matchedRoutes: [{ options: { server: { middleware: [] } } }], + foundRoute: { + options: { + server: { handlers: currentHandlers }, + component: undefined, + }, + }, + routeParams: {}, + }), + + update: () => {}, + load: async () => {}, + state: { redirect: null } as any, + serverSsr: { dehydrate: async () => {} } as any, + options: {} as any, + resolveRedirect: (r: any) => r, + } as unknown as AnyRouter +} + +export async function getRouter() { + return makeFakeRouter() +} diff --git a/packages/start-server-core/tests/mocks/start-entry.ts b/packages/start-server-core/tests/mocks/start-entry.ts new file mode 100644 index 00000000000..7d67fb23e41 --- /dev/null +++ b/packages/start-server-core/tests/mocks/start-entry.ts @@ -0,0 +1,7 @@ +export const startInstance = { + getOptions: async () => ({ + requestMiddleware: undefined, + defaultSsr: undefined, + serializationAdapters: [], + }), +} diff --git a/packages/start-server-core/tests/mocks/start-manifest.ts b/packages/start-server-core/tests/mocks/start-manifest.ts new file mode 100644 index 00000000000..a2cc4ab9ec1 --- /dev/null +++ b/packages/start-server-core/tests/mocks/start-manifest.ts @@ -0,0 +1,10 @@ +export const tsrStartManifest = () => ({ + routes: { + __root__: { + id: '__root__', + }, + }, + routeTree: { + id: '__root__', + }, +}) diff --git a/packages/start-server-core/vite.config.ts b/packages/start-server-core/vite.config.ts index f0ca2cc699f..d40904d740d 100644 --- a/packages/start-server-core/vite.config.ts +++ b/packages/start-server-core/vite.config.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/config/vite' import packageJson from './package.json' @@ -12,6 +13,26 @@ const config = defineConfig({ watch: false, environment: 'jsdom', }, + resolve: { + alias: { + '#tanstack-router-entry': path.resolve( + __dirname, + './tests/mocks/router-entry.ts', + ), + '#tanstack-start-entry': path.resolve( + __dirname, + './tests/mocks/start-entry.ts', + ), + 'tanstack-start-manifest:v': path.resolve( + __dirname, + './tests/mocks/start-manifest.ts', + ), + 'tanstack-start-injected-head-scripts:v': path.resolve( + __dirname, + './tests/mocks/injected-head-scripts.ts', + ), + }, + }, }) export default mergeConfig(