From a317d252087df7ccb1b597d4f6ce2e1b495df3e1 Mon Sep 17 00:00:00 2001 From: Julius Date: Fri, 12 Dec 2025 21:53:25 -0800 Subject: [PATCH 1/4] throw 405 response instead of 500 internal error on method mismatch --- .../start-server-core/src/server-functions-handler.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index 69e0dc19bc4..b9f8b82f34b 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -12,6 +12,15 @@ import { getServerFnById } from './getServerFnById' let regex: RegExp | undefined = undefined +const methodNotAllowed = (expectedMethod: string, actualMethod: string) => { + throw new Response(`expected ${expectedMethod} method. Got ${actualMethod}`, { + status: 405, + headers: { + Allow: expectedMethod, + }, + }) +} + export const handleServerAction = async ({ request, context, @@ -119,7 +128,7 @@ export const handleServerAction = async ({ } if (method.toLowerCase() !== 'post') { - throw new Error('expected POST method') + throw methodNotAllowed('POST', method) } let jsonPayload From c3354300959103e716006cff82082349d3dd368c Mon Sep 17 00:00:00 2001 From: Julius Date: Sat, 13 Dec 2025 11:40:31 -0800 Subject: [PATCH 2/4] add tests --- .../server-functions/src/routeTree.gen.ts | 42 +++++++++++++++++ .../src/routes/method-not-allowed/get.tsx | 44 ++++++++++++++++++ .../src/routes/method-not-allowed/post.tsx | 44 ++++++++++++++++++ .../tests/server-functions.spec.ts | 46 +++++++++++++++++++ .../server-functions/vite.config.ts | 3 ++ 5 files changed, 179 insertions(+) create mode 100644 e2e/react-start/server-functions/src/routes/method-not-allowed/get.tsx create mode 100644 e2e/react-start/server-functions/src/routes/method-not-allowed/post.tsx diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index 51327cb8164..34595c339d0 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -35,6 +35,8 @@ import { Route as RedirectTestSsrTargetRouteImport } from './routes/redirect-tes import { Route as MiddlewareSendServerFnRouteImport } from './routes/middleware/send-serverFn' import { Route as MiddlewareRequestMiddlewareRouteImport } from './routes/middleware/request-middleware' import { Route as MiddlewareClientMiddlewareRouterRouteImport } from './routes/middleware/client-middleware-router' +import { Route as MethodNotAllowedPostRouteImport } from './routes/method-not-allowed/post' +import { Route as MethodNotAllowedGetRouteImport } from './routes/method-not-allowed/get' import { Route as CookiesSetRouteImport } from './routes/cookies/set' import { Route as FormdataRedirectTargetNameRouteImport } from './routes/formdata-redirect/target.$name' @@ -170,6 +172,16 @@ const MiddlewareClientMiddlewareRouterRoute = path: '/middleware/client-middleware-router', getParentRoute: () => rootRouteImport, } as any) +const MethodNotAllowedPostRoute = MethodNotAllowedPostRouteImport.update({ + id: '/method-not-allowed/post', + path: '/method-not-allowed/post', + getParentRoute: () => rootRouteImport, +} as any) +const MethodNotAllowedGetRoute = MethodNotAllowedGetRouteImport.update({ + id: '/method-not-allowed/get', + path: '/method-not-allowed/get', + getParentRoute: () => rootRouteImport, +} as any) const CookiesSetRoute = CookiesSetRouteImport.update({ id: '/cookies/set', path: '/cookies/set', @@ -198,6 +210,8 @@ export interface FileRoutesByFullPath { '/status': typeof StatusRoute '/submit-post-formdata': typeof SubmitPostFormdataRoute '/cookies/set': typeof CookiesSetRoute + '/method-not-allowed/get': typeof MethodNotAllowedGetRoute + '/method-not-allowed/post': typeof MethodNotAllowedPostRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute @@ -228,6 +242,8 @@ export interface FileRoutesByTo { '/status': typeof StatusRoute '/submit-post-formdata': typeof SubmitPostFormdataRoute '/cookies/set': typeof CookiesSetRoute + '/method-not-allowed/get': typeof MethodNotAllowedGetRoute + '/method-not-allowed/post': typeof MethodNotAllowedPostRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute @@ -259,6 +275,8 @@ export interface FileRoutesById { '/status': typeof StatusRoute '/submit-post-formdata': typeof SubmitPostFormdataRoute '/cookies/set': typeof CookiesSetRoute + '/method-not-allowed/get': typeof MethodNotAllowedGetRoute + '/method-not-allowed/post': typeof MethodNotAllowedPostRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute @@ -291,6 +309,8 @@ export interface FileRouteTypes { | '/status' | '/submit-post-formdata' | '/cookies/set' + | '/method-not-allowed/get' + | '/method-not-allowed/post' | '/middleware/client-middleware-router' | '/middleware/request-middleware' | '/middleware/send-serverFn' @@ -321,6 +341,8 @@ export interface FileRouteTypes { | '/status' | '/submit-post-formdata' | '/cookies/set' + | '/method-not-allowed/get' + | '/method-not-allowed/post' | '/middleware/client-middleware-router' | '/middleware/request-middleware' | '/middleware/send-serverFn' @@ -351,6 +373,8 @@ export interface FileRouteTypes { | '/status' | '/submit-post-formdata' | '/cookies/set' + | '/method-not-allowed/get' + | '/method-not-allowed/post' | '/middleware/client-middleware-router' | '/middleware/request-middleware' | '/middleware/send-serverFn' @@ -382,6 +406,8 @@ export interface RootRouteChildren { StatusRoute: typeof StatusRoute SubmitPostFormdataRoute: typeof SubmitPostFormdataRoute CookiesSetRoute: typeof CookiesSetRoute + MethodNotAllowedGetRoute: typeof MethodNotAllowedGetRoute + MethodNotAllowedPostRoute: typeof MethodNotAllowedPostRoute MiddlewareClientMiddlewareRouterRoute: typeof MiddlewareClientMiddlewareRouterRoute MiddlewareRequestMiddlewareRoute: typeof MiddlewareRequestMiddlewareRoute MiddlewareSendServerFnRoute: typeof MiddlewareSendServerFnRoute @@ -581,6 +607,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MiddlewareClientMiddlewareRouterRouteImport parentRoute: typeof rootRouteImport } + '/method-not-allowed/post': { + id: '/method-not-allowed/post' + path: '/method-not-allowed/post' + fullPath: '/method-not-allowed/post' + preLoaderRoute: typeof MethodNotAllowedPostRouteImport + parentRoute: typeof rootRouteImport + } + '/method-not-allowed/get': { + id: '/method-not-allowed/get' + path: '/method-not-allowed/get' + fullPath: '/method-not-allowed/get' + preLoaderRoute: typeof MethodNotAllowedGetRouteImport + parentRoute: typeof rootRouteImport + } '/cookies/set': { id: '/cookies/set' path: '/cookies/set' @@ -614,6 +654,8 @@ const rootRouteChildren: RootRouteChildren = { StatusRoute: StatusRoute, SubmitPostFormdataRoute: SubmitPostFormdataRoute, CookiesSetRoute: CookiesSetRoute, + MethodNotAllowedGetRoute: MethodNotAllowedGetRoute, + MethodNotAllowedPostRoute: MethodNotAllowedPostRoute, MiddlewareClientMiddlewareRouterRoute: MiddlewareClientMiddlewareRouterRoute, MiddlewareRequestMiddlewareRoute: MiddlewareRequestMiddlewareRoute, MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute, diff --git a/e2e/react-start/server-functions/src/routes/method-not-allowed/get.tsx b/e2e/react-start/server-functions/src/routes/method-not-allowed/get.tsx new file mode 100644 index 00000000000..514c88e9e8c --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/method-not-allowed/get.tsx @@ -0,0 +1,44 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { useState } from 'react' + +export const Route = createFileRoute('/method-not-allowed/get')({ + component: MethodNotAllowedFn, +}) + +export const getableServerFn = createServerFn({ method: 'GET' }).handler(() => { + return new Response('Hello, World!') +}) + +const fetchFn = async (method: string) => { + const response = await fetch('/_serverFn/constant_id_2?createServerFn', { + method, + }) + return [response.status, await response.text()] as const +} + +function MethodNotAllowedFn() { + const [fetchResult, setFetchResult] = useState< + readonly [number, string] | null + >(null) + return ( +
+

Method Not Allowed GET

+ + + + + + +
{JSON.stringify(fetchResult)}
+
+ ) +} diff --git a/e2e/react-start/server-functions/src/routes/method-not-allowed/post.tsx b/e2e/react-start/server-functions/src/routes/method-not-allowed/post.tsx new file mode 100644 index 00000000000..81c3dd6e62b --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/method-not-allowed/post.tsx @@ -0,0 +1,44 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { useState } from 'react' + +export const Route = createFileRoute('/method-not-allowed/post')({ + component: MethodNotAllowedFn, +}) + +export const postableServerFn = createServerFn({ method: 'POST' }).handler(() => { + return new Response('Hello, World!') +}) + +const fetchFn = async (method: string) => { + const response = await fetch('/_serverFn/constant_id_3?createServerFn', { + method, + }) + return [response.status, await response.text()] as const +} + +function MethodNotAllowedFn() { + const [fetchResult, setFetchResult] = useState< + readonly [number, string] | null + >(null) + return ( +
+

Method Not Allowed POST

+ + + + + + +
{JSON.stringify(fetchResult)}
+
+ ) +} diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index b384be2ea6f..8b42143fddf 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -541,3 +541,49 @@ test('redirect in server function called in query during SSR', async ({ await expect(page.getByTestId('redirect-target-ssr')).toBeVisible() expect(page.url()).toContain('/redirect-test-ssr/target') }) + +test.describe.only('server function returns 405 when method is not allowed', () => { + test('serverFn defined with GET method', async ({ page }) => { + await page.goto('/method-not-allowed/get') + + await page.waitForLoadState('networkidle') + + await page.getByTestId('get-button').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('fetch-result')).toContainText('[200,"Hello, World!"]') + + await page.getByTestId('post-button').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected GET method. Got POST"]') + + await page.getByTestId('put-button').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected GET method. Got PUT"]') + + await page.getByTestId('options-button').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected GET method. Got OPTIONS"]') + }) + + test('serverFn defined with POST method', async ({ page }) => { + await page.goto('/method-not-allowed/post') + + await page.waitForLoadState('networkidle') + + await page.getByTestId('get-button').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected POST method. Got GET"]') + + await page.getByTestId('post-button').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('fetch-result')).toContainText('[200,"Hello, World!"]') + + await page.getByTestId('put-button').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected POST method. Got PUT"]') + + await page.getByTestId('options-button').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected POST method. Got OPTIONS"]') + }) +}) \ No newline at end of file diff --git a/e2e/react-start/server-functions/vite.config.ts b/e2e/react-start/server-functions/vite.config.ts index 97ff657630e..9fb8034a444 100644 --- a/e2e/react-start/server-functions/vite.config.ts +++ b/e2e/react-start/server-functions/vite.config.ts @@ -6,6 +6,8 @@ import viteReact from '@vitejs/plugin-react' const FUNCTIONS_WITH_CONSTANT_ID = [ 'src/routes/submit-post-formdata.tsx/greetUser_createServerFn_handler', 'src/routes/formdata-redirect/index.tsx/greetUser_createServerFn_handler', + 'src/routes/method-not-allowed/get.tsx/getableServerFn_createServerFn_handler', + 'src/routes/method-not-allowed/post.tsx/postableServerFn_createServerFn_handler', ] export default defineConfig({ @@ -17,6 +19,7 @@ export default defineConfig({ serverFns: { generateFunctionId: (opts) => { const id = `${opts.filename}/${opts.functionName}` + console.log('serverFn', id) if (FUNCTIONS_WITH_CONSTANT_ID.includes(id)) return 'constant_id' else return undefined }, From 95ae2c566eb1d9df59e019c925fef3d664d11be5 Mon Sep 17 00:00:00 2001 From: Julius Date: Sat, 13 Dec 2025 11:51:00 -0800 Subject: [PATCH 3/4] rm log, .only --- e2e/react-start/server-functions/tests/server-functions.spec.ts | 2 +- e2e/react-start/server-functions/vite.config.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index 8b42143fddf..da3422882c7 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -542,7 +542,7 @@ test('redirect in server function called in query during SSR', async ({ expect(page.url()).toContain('/redirect-test-ssr/target') }) -test.describe.only('server function returns 405 when method is not allowed', () => { +test.describe('server function returns 405 when method is not allowed', () => { test('serverFn defined with GET method', async ({ page }) => { await page.goto('/method-not-allowed/get') diff --git a/e2e/react-start/server-functions/vite.config.ts b/e2e/react-start/server-functions/vite.config.ts index 9fb8034a444..3df377bbd3f 100644 --- a/e2e/react-start/server-functions/vite.config.ts +++ b/e2e/react-start/server-functions/vite.config.ts @@ -19,7 +19,6 @@ export default defineConfig({ serverFns: { generateFunctionId: (opts) => { const id = `${opts.filename}/${opts.functionName}` - console.log('serverFn', id) if (FUNCTIONS_WITH_CONSTANT_ID.includes(id)) return 'constant_id' else return undefined }, From d60bd34a4c658d2f6a9b96088fae60b60e375d4a Mon Sep 17 00:00:00 2001 From: Julius Date: Sat, 13 Dec 2025 11:51:32 -0800 Subject: [PATCH 4/4] fmt --- .../src/routes/method-not-allowed/get.tsx | 22 +++++++++--- .../src/routes/method-not-allowed/post.tsx | 30 +++++++++++----- .../tests/server-functions.spec.ts | 34 ++++++++++++++----- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/e2e/react-start/server-functions/src/routes/method-not-allowed/get.tsx b/e2e/react-start/server-functions/src/routes/method-not-allowed/get.tsx index 514c88e9e8c..c07679ee475 100644 --- a/e2e/react-start/server-functions/src/routes/method-not-allowed/get.tsx +++ b/e2e/react-start/server-functions/src/routes/method-not-allowed/get.tsx @@ -22,19 +22,31 @@ function MethodNotAllowedFn() { readonly [number, string] | null >(null) return ( -
+

Method Not Allowed GET

- - - - diff --git a/e2e/react-start/server-functions/src/routes/method-not-allowed/post.tsx b/e2e/react-start/server-functions/src/routes/method-not-allowed/post.tsx index 81c3dd6e62b..e25a4b588b5 100644 --- a/e2e/react-start/server-functions/src/routes/method-not-allowed/post.tsx +++ b/e2e/react-start/server-functions/src/routes/method-not-allowed/post.tsx @@ -6,9 +6,11 @@ export const Route = createFileRoute('/method-not-allowed/post')({ component: MethodNotAllowedFn, }) -export const postableServerFn = createServerFn({ method: 'POST' }).handler(() => { - return new Response('Hello, World!') -}) +export const postableServerFn = createServerFn({ method: 'POST' }).handler( + () => { + return new Response('Hello, World!') + }, +) const fetchFn = async (method: string) => { const response = await fetch('/_serverFn/constant_id_3?createServerFn', { @@ -22,19 +24,31 @@ function MethodNotAllowedFn() { readonly [number, string] | null >(null) return ( -
+

Method Not Allowed POST

- - - - diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index da3422882c7..8b2f2cedc5d 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -550,19 +550,27 @@ test.describe('server function returns 405 when method is not allowed', () => { await page.getByTestId('get-button').click() await page.waitForLoadState('networkidle') - await expect(page.getByTestId('fetch-result')).toContainText('[200,"Hello, World!"]') + await expect(page.getByTestId('fetch-result')).toContainText( + '[200,"Hello, World!"]', + ) await page.getByTestId('post-button').click() await page.waitForLoadState('networkidle') - await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected GET method. Got POST"]') + await expect(page.getByTestId('fetch-result')).toContainText( + '[405,"expected GET method. Got POST"]', + ) await page.getByTestId('put-button').click() await page.waitForLoadState('networkidle') - await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected GET method. Got PUT"]') + await expect(page.getByTestId('fetch-result')).toContainText( + '[405,"expected GET method. Got PUT"]', + ) await page.getByTestId('options-button').click() await page.waitForLoadState('networkidle') - await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected GET method. Got OPTIONS"]') + await expect(page.getByTestId('fetch-result')).toContainText( + '[405,"expected GET method. Got OPTIONS"]', + ) }) test('serverFn defined with POST method', async ({ page }) => { @@ -572,18 +580,26 @@ test.describe('server function returns 405 when method is not allowed', () => { await page.getByTestId('get-button').click() await page.waitForLoadState('networkidle') - await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected POST method. Got GET"]') + await expect(page.getByTestId('fetch-result')).toContainText( + '[405,"expected POST method. Got GET"]', + ) await page.getByTestId('post-button').click() await page.waitForLoadState('networkidle') - await expect(page.getByTestId('fetch-result')).toContainText('[200,"Hello, World!"]') + await expect(page.getByTestId('fetch-result')).toContainText( + '[200,"Hello, World!"]', + ) await page.getByTestId('put-button').click() await page.waitForLoadState('networkidle') - await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected POST method. Got PUT"]') + await expect(page.getByTestId('fetch-result')).toContainText( + '[405,"expected POST method. Got PUT"]', + ) await page.getByTestId('options-button').click() await page.waitForLoadState('networkidle') - await expect(page.getByTestId('fetch-result')).toContainText('[405,"expected POST method. Got OPTIONS"]') + await expect(page.getByTestId('fetch-result')).toContainText( + '[405,"expected POST method. Got OPTIONS"]', + ) }) -}) \ No newline at end of file +})