Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/untagged-server-fn-response.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 5 additions & 7 deletions packages/start-client-core/src/client-rpc/serverFnFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,13 +331,11 @@ async function getResponse(fn: () => Promise<Response>) {
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())
}

/**
Expand Down
64 changes: 64 additions & 0 deletions packages/start-client-core/tests/server-fn-fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<!DOCTYPE html><html><body>challenge</body></html>'

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')
})
})