Skip to content

Commit 16d4a8a

Browse files
committed
fix(start-server-core): return 404 for API routes without GET handler
1 parent 6baffeb commit 16d4a8a

File tree

7 files changed

+162
-4
lines changed

7 files changed

+162
-4
lines changed

packages/start-server-core/src/createStartHandler.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,12 +380,32 @@ async function handleServerRoutes({
380380
createHandlers: (d: any) => d,
381381
})
382382
: server.handlers
383+
const normalizedHandlers: Record<string, any> = {}
384+
for (const [k, v] of Object.entries(handlers)) {
385+
normalizedHandlers[k.toUpperCase()] = v
386+
}
383387

384-
const requestMethod = request.method.toUpperCase() as RouteMethod
385-
388+
let requestMethod = request.method.toUpperCase() as RouteMethod
389+
if (requestMethod === 'HEAD' && normalizedHandlers["GET"]) {
390+
requestMethod = 'GET' as RouteMethod
391+
}
392+
const hasAny = !!normalizedHandlers["ANY"]
386393
// Attempt to find the method in the handlers
387-
const handler = handlers[requestMethod] ?? handlers['ANY']
394+
const handler = normalizedHandlers[requestMethod] ?? normalizedHandlers["ANY"]
395+
if (!handler && !hasAny) {
388396

397+
if (request.method.toUpperCase() === 'HEAD') {
398+
return new Response(null, {
399+
status: 404,
400+
headers: { 'Content-Type': 'application/json' },
401+
})
402+
}
403+
404+
return new Response(JSON.stringify({ error: 'Not Found' }), {
405+
status: 404,
406+
headers: { 'Content-Type': 'application/json' },
407+
})
408+
}
389409
// If a method is found, execute the handler
390410
if (handler) {
391411
const mayDefer = !!foundRoute.options.component
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { createStartHandler } from '../src'
3+
import { currentHandlers } from './mocks/router-entry'
4+
5+
6+
const spaFallback = async () =>
7+
new Response('<!doctype html><div>spa</div>', {
8+
status: 200,
9+
headers: { 'Content-Type': 'text/html' },
10+
})
11+
12+
function makeApp() {
13+
return createStartHandler(async () => await spaFallback())
14+
}
15+
beforeEach(() => {
16+
Object.keys(currentHandlers).forEach(key => delete currentHandlers[key])
17+
})
18+
19+
describe('createStartHandler — server route HTTP method handling', function () {
20+
it('should return 404 JSON for GET when only POST is defined (no SPA fallback)', async function () {
21+
currentHandlers.POST = () => new Response('ok', { status: 200 })
22+
const app = makeApp()
23+
24+
const res = await app(
25+
new Request('http://localhost/api/test-no-get', { method: 'GET' }),
26+
)
27+
28+
expect(res.status).toBe(404)
29+
expect(res.headers.get('content-type')).toMatch(/application\/json/i)
30+
const txt = await res.text()
31+
expect(txt).toContain('Not Found')
32+
expect(txt.toLowerCase().startsWith('<!doctype html>')).toBe(false)
33+
})
34+
35+
it('should return 200 for POST and execute the route handler', async function () {
36+
currentHandlers.POST = () => new Response('ok', { status: 200 })
37+
const app = makeApp()
38+
39+
const res = await app(
40+
new Request('http://localhost/api/test-no-get', { method: 'POST' }),
41+
)
42+
43+
expect(res.status).toBe(200)
44+
expect(await res.text()).toBe('ok')
45+
})
46+
47+
it('should return 404 for HEAD when GET is not defined', async function () {
48+
currentHandlers.POST = () => new Response('ok', { status: 200 })
49+
const app = makeApp()
50+
51+
const res = await app(
52+
new Request('http://localhost/api/test-no-get', { method: 'HEAD' }),
53+
)
54+
55+
expect(res.status).toBe(404)
56+
})
57+
58+
it('should use GET handler when HEAD is requested and GET exists', async function () {
59+
currentHandlers.GET = () => new Response('hello', { status: 200 })
60+
const app = makeApp()
61+
62+
const res = await app(
63+
new Request('http://localhost/api/has-get', { method: 'HEAD' }),
64+
)
65+
66+
expect(res.status).toBe(200)
67+
})
68+
69+
it('should execute ANY handler for unsupported methods (e.g., PUT)', async function () {
70+
currentHandlers.ANY = () => new Response('ok-any', { status: 200 })
71+
const app = makeApp()
72+
73+
const res = await app(
74+
new Request('http://localhost/api/any', { method: 'PUT' }),
75+
)
76+
77+
expect(res.status).toBe(200)
78+
expect(await res.text()).toBe('ok-any')
79+
})
80+
})
81+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const injectedHeadScripts = ''
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AnyRouter } from '@tanstack/router-core'
2+
3+
export let currentHandlers: Record<string, any> = {}
4+
5+
function makeFakeRouter(): AnyRouter {
6+
return {
7+
rewrite: undefined as any,
8+
getMatchedRoutes: (_pathname: string) => ({
9+
matchedRoutes: [{ options: { server: { middleware: [] } } }],
10+
foundRoute: {
11+
options: {
12+
server: { handlers: currentHandlers },
13+
component: undefined,
14+
},
15+
},
16+
routeParams: {},
17+
}),
18+
19+
update: () => {},
20+
load: async () => {},
21+
state: { redirect: null } as any,
22+
serverSsr: { dehydrate: async () => {} } as any,
23+
options: {} as any,
24+
resolveRedirect: (r: any) => r,
25+
} as unknown as AnyRouter
26+
}
27+
28+
export async function getRouter() {
29+
return makeFakeRouter()
30+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const startInstance = {
2+
getOptions: async () => ({
3+
requestMiddleware: undefined,
4+
defaultSsr: undefined,
5+
serializationAdapters: [],
6+
}),
7+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const tsrStartManifest = () => ({
2+
routes: {
3+
__root__: {
4+
id: '__root__',
5+
},
6+
},
7+
routeTree: {
8+
id: '__root__',
9+
},
10+
})

packages/start-server-core/vite.config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,23 @@ import packageJson from './package.json'
44
// this needs to be imported from the actual file instead of from 'index.tsx'
55
// so we don't trigger the import of a `?script-string` import before the minifyScriptPlugin is setup
66
import { VIRTUAL_MODULES } from './src/virtual-modules'
7-
7+
import path from 'path'
88
const config = defineConfig({
99
test: {
1010
include: ['**/*.{test-d,test,spec}.?(c|m)[jt]s?(x)'],
1111
name: packageJson.name,
1212
watch: false,
1313
environment: 'jsdom',
1414
},
15+
resolve: {
16+
alias: {
17+
'#tanstack-router-entry': path.resolve(__dirname, './tests/mocks/router-entry.ts'),
18+
'#tanstack-start-entry': path.resolve(__dirname, './tests/mocks/start-entry.ts'),
19+
'tanstack-start-manifest:v': path.resolve(__dirname, './tests/mocks/start-manifest.ts'),
20+
'tanstack-start-injected-head-scripts:v': path.resolve(__dirname, './tests/mocks/injected-head-scripts.ts'),
21+
22+
}
23+
}
1524
})
1625

1726
export default mergeConfig(

0 commit comments

Comments
 (0)