From 910941b81b29ed0392d514a8adc7d6a82df15b43 Mon Sep 17 00:00:00 2001 From: mixelburg Date: Tue, 15 Sep 2026 22:10:37 +0000 Subject: [PATCH] fix(start-client-core): reject untagged non-JSON server function responses --- .changeset/untagged-server-fn-response.md | 5 ++ .../src/client-rpc/serverFnFetcher.ts | 12 ++-- .../tests/server-fn-fetcher.test.ts | 64 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 .changeset/untagged-server-fn-response.md create mode 100644 packages/start-client-core/tests/server-fn-fetcher.test.ts diff --git a/.changeset/untagged-server-fn-response.md b/.changeset/untagged-server-fn-response.md new file mode 100644 index 00000000000..c4f941d577f --- /dev/null +++ b/.changeset/untagged-server-fn-response.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-client-core': patch +--- + +Server function calls now reject when a response is untagged and not JSON, instead of returning the `Response` object as the function's result. An untagged response did not come from the server-functions handler — something between the browser and the server answered the request instead (a reverse proxy, a bot challenge, a captive portal) — so the body is surfaced as an error, matching how untagged non-JSON error responses are already handled. diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 2534b6bb938..5eb5e907d74 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -331,13 +331,11 @@ async function getResponse(fn: () => Promise) { return jsonPayload } - // Otherwise, if it's not OK, throw the content - if (!response.ok) { - throw new Error(await response.text()) - } - - // Or return the response itself - return response + // Otherwise, throw the content. An untagged response that isn't JSON did not + // come from the server-functions handler - something between the browser and + // the server answered instead (a proxy, a bot challenge, a captive portal), + // so it must not be handed back as the function's result. + throw new Error(await response.text()) } /** diff --git a/packages/start-client-core/tests/server-fn-fetcher.test.ts b/packages/start-client-core/tests/server-fn-fetcher.test.ts new file mode 100644 index 00000000000..bd99417c581 --- /dev/null +++ b/packages/start-client-core/tests/server-fn-fetcher.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' + +// The isomorphic fn stubs resolve to their server implementation when they are +// not compiled, which needs a Start context that does not exist in a unit test. +vi.mock('../src/getStartOptions', () => ({ + getStartOptions: () => undefined, +})) + +const { serverFnFetcher } = await import('../src/client-rpc/serverFnFetcher') + +function fetchReturning(response: Response) { + return async () => response +} + +const args = [{ method: 'GET' as const, data: undefined }] + +describe('serverFnFetcher', () => { + it('rejects an untagged non-JSON response instead of returning it', async () => { + const interstitial = 'challenge' + + await expect( + serverFnFetcher( + '/_serverFn/abc', + args, + fetchReturning( + new Response(interstitial, { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + ), + ), + ).rejects.toThrow(interstitial) + }) + + it('rejects an untagged non-JSON error response with the body', async () => { + await expect( + serverFnFetcher( + '/_serverFn/abc', + args, + fetchReturning( + new Response('blocked by bot management', { + status: 403, + headers: { 'content-type': 'text/plain' }, + }), + ), + ), + ).rejects.toThrow('blocked by bot management') + }) + + it('still returns a raw response marked with x-tss-raw', async () => { + const response = await serverFnFetcher( + '/_serverFn/abc', + args, + fetchReturning( + new Response('file contents', { + status: 200, + headers: { 'content-type': 'text/plain', 'x-tss-raw': 'true' }, + }), + ), + ) + + expect(await (response as Response).text()).toBe('file contents') + }) +})