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
22 changes: 22 additions & 0 deletions e2e/react-start/server-functions/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Route as MiddlewareIndexRouteImport } from './routes/middleware/index'
import { Route as FormdataRedirectIndexRouteImport } from './routes/formdata-redirect/index'
import { Route as FactoryIndexRouteImport } from './routes/factory/index'
import { Route as CookiesIndexRouteImport } from './routes/cookies/index'
import { Route as MiddlewareUnhandledExceptionRouteImport } from './routes/middleware/unhandled-exception'
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'
Expand Down Expand Up @@ -123,6 +124,12 @@ const CookiesIndexRoute = CookiesIndexRouteImport.update({
path: '/cookies/',
getParentRoute: () => rootRouteImport,
} as any)
const MiddlewareUnhandledExceptionRoute =
MiddlewareUnhandledExceptionRouteImport.update({
id: '/middleware/unhandled-exception',
path: '/middleware/unhandled-exception',
getParentRoute: () => rootRouteImport,
} as any)
const MiddlewareSendServerFnRoute = MiddlewareSendServerFnRouteImport.update({
id: '/middleware/send-serverFn',
path: '/middleware/send-serverFn',
Expand Down Expand Up @@ -170,6 +177,7 @@ export interface FileRoutesByFullPath {
'/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute
'/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute
'/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute
'/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute
'/cookies': typeof CookiesIndexRoute
'/factory': typeof FactoryIndexRoute
'/formdata-redirect': typeof FormdataRedirectIndexRoute
Expand All @@ -195,6 +203,7 @@ export interface FileRoutesByTo {
'/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute
'/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute
'/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute
'/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute
'/cookies': typeof CookiesIndexRoute
'/factory': typeof FactoryIndexRoute
'/formdata-redirect': typeof FormdataRedirectIndexRoute
Expand All @@ -221,6 +230,7 @@ export interface FileRoutesById {
'/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute
'/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute
'/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute
'/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute
'/cookies/': typeof CookiesIndexRoute
'/factory/': typeof FactoryIndexRoute
'/formdata-redirect/': typeof FormdataRedirectIndexRoute
Expand Down Expand Up @@ -248,6 +258,7 @@ export interface FileRouteTypes {
| '/middleware/client-middleware-router'
| '/middleware/request-middleware'
| '/middleware/send-serverFn'
| '/middleware/unhandled-exception'
| '/cookies'
| '/factory'
| '/formdata-redirect'
Expand All @@ -273,6 +284,7 @@ export interface FileRouteTypes {
| '/middleware/client-middleware-router'
| '/middleware/request-middleware'
| '/middleware/send-serverFn'
| '/middleware/unhandled-exception'
| '/cookies'
| '/factory'
| '/formdata-redirect'
Expand All @@ -298,6 +310,7 @@ export interface FileRouteTypes {
| '/middleware/client-middleware-router'
| '/middleware/request-middleware'
| '/middleware/send-serverFn'
| '/middleware/unhandled-exception'
| '/cookies/'
| '/factory/'
| '/formdata-redirect/'
Expand All @@ -324,6 +337,7 @@ export interface RootRouteChildren {
MiddlewareClientMiddlewareRouterRoute: typeof MiddlewareClientMiddlewareRouterRoute
MiddlewareRequestMiddlewareRoute: typeof MiddlewareRequestMiddlewareRoute
MiddlewareSendServerFnRoute: typeof MiddlewareSendServerFnRoute
MiddlewareUnhandledExceptionRoute: typeof MiddlewareUnhandledExceptionRoute
CookiesIndexRoute: typeof CookiesIndexRoute
FactoryIndexRoute: typeof FactoryIndexRoute
FormdataRedirectIndexRoute: typeof FormdataRedirectIndexRoute
Expand Down Expand Up @@ -460,6 +474,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CookiesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/middleware/unhandled-exception': {
id: '/middleware/unhandled-exception'
path: '/middleware/unhandled-exception'
fullPath: '/middleware/unhandled-exception'
preLoaderRoute: typeof MiddlewareUnhandledExceptionRouteImport
parentRoute: typeof rootRouteImport
}
'/middleware/send-serverFn': {
id: '/middleware/send-serverFn'
path: '/middleware/send-serverFn'
Expand Down Expand Up @@ -516,6 +537,7 @@ const rootRouteChildren: RootRouteChildren = {
MiddlewareClientMiddlewareRouterRoute: MiddlewareClientMiddlewareRouterRoute,
MiddlewareRequestMiddlewareRoute: MiddlewareRequestMiddlewareRoute,
MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute,
MiddlewareUnhandledExceptionRoute: MiddlewareUnhandledExceptionRoute,
CookiesIndexRoute: CookiesIndexRoute,
FactoryIndexRoute: FactoryIndexRoute,
FormdataRedirectIndexRoute: FormdataRedirectIndexRoute,
Expand Down
5 changes: 5 additions & 0 deletions e2e/react-start/server-functions/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ function Home() {
<li>
<Link to="/factory">Server Functions Factory E2E tests</Link>
</li>
<li>
<Link to="/middleware/unhandled-exception">
Server Functions Middleware Unhandled Exception E2E tests
</Link>
</li>
</ul>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createFileRoute } from '@tanstack/react-router'
import { createMiddleware, createServerFn } from '@tanstack/react-start'

export const authMiddleware = createMiddleware({ type: 'function' }).server(
async ({ next, context }) => {
throw new Error('Unauthorized')
},
)

const personServerFn = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.inputValidator((d: string) => d)
.handler(({ data: name }) => {
return { name, randomNumber: Math.floor(Math.random() * 100) }
})

export const Route = createFileRoute('/middleware/unhandled-exception')({
loader: async () => {
return {
person: await personServerFn({ data: 'John Doe' }),
}
},
component: RouteComponent,
})

function RouteComponent() {
const { person } = Route.useLoaderData()
return (
<div data-testid="regular-person">
{person.name} - {person.randomNumber}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function SubmitPostFormDataFn() {
<div className="p-2 m-2 grid gap-2">
<h3>Submit POST FormData Fn Call</h3>
<div className="overflow-y-auto">
It should return navigate and return{' '}
It should navigate to a raw response of {''}
<code>
<pre data-testid="expected-submit-post-formdata-server-fn-result">
Hello, {testValues.name}!
Expand Down
47 changes: 29 additions & 18 deletions packages/start-client-core/src/client-rpc/serverFnFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ import type { Plugin as SerovalPlugin } from 'seroval'

let serovalPlugins: Array<SerovalPlugin<any, any>> | null = null

// caller =>
// serverFnFetcher =>
// client =>
// server =>
// fn =>
// seroval =>
// client middleware =>
// serverFnFetcher =>
// caller

export async function serverFnFetcher(
url: string,
args: Array<any>,
Expand All @@ -37,7 +47,8 @@ export async function serverFnFetcher(

// Arrange the headers
const headers = new Headers({
'x-tsr-redirect': 'manual',
'x-tsr-serverFn': 'true',
'x-tsr-createServerFn': 'true',
...(first.headers instanceof Headers
? Object.fromEntries(first.headers.entries())
: first.headers),
Expand Down Expand Up @@ -65,12 +76,6 @@ export async function serverFnFetcher(
}
}

if (url.includes('?')) {
url += `&createServerFn`
} else {
url += `?createServerFn`
}

let body = undefined
if (first.method === 'POST') {
const fetchBody = await getFetchBody(first)
Expand All @@ -97,6 +102,7 @@ export async function serverFnFetcher(
handler(url, {
method: 'POST',
headers: {
'x-tsr-serverFn': 'true',
Accept: 'application/json',
'Content-Type': 'application/json',
},
Expand Down Expand Up @@ -165,7 +171,7 @@ async function getFetchBody(
async function getResponse(fn: () => Promise<Response>) {
const response = await (async () => {
try {
return await fn()
return await fn() // client => server => fn => server => client
} catch (error) {
if (error instanceof Response) {
return error
Expand All @@ -178,22 +184,16 @@ async function getResponse(fn: () => Promise<Response>) {
if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') {
return response
}

const contentType = response.headers.get('content-type')
invariant(contentType, 'expected content-type header to be set')
const serializedByStart = !!response.headers.get(X_TSS_SERIALIZED)
// If the response is not ok, throw an error
if (!response.ok) {
if (serializedByStart && contentType.includes('application/json')) {
const jsonPayload = await response.json()
const result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
throw result
}

throw new Error(await response.text())
}

Comment on lines 189 to 191
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Handle no-content responses; don’t assert content-type.

Server may return undefined/204 with no content-type (e.g., serializeResult(undefined)). Current invariant throws. Treat “no content” as undefined when ok.

-  invariant(contentType, 'expected content-type header to be set')
+  const contentType = response.headers.get('content-type')
+  if (!contentType) {
+    if (response.ok && (response.status === 204 || !response.body)) {
+      return undefined
+    }
+    if (!response.ok) {
+      throw new Error(await response.text())
+    }
+    return response
+  }

Also applies to: 288-294

🤖 Prompt for AI Agents
In packages/start-client-core/src/client-rpc/serverFnFetcher.ts around lines
189-191 (and similarly at 288-294), the code currently invariants that a
content-type header exists which breaks for no-content responses (e.g., 204 or
serializeResult(undefined)); change the logic to allow missing content-type for
empty responses: first detect no-content (response.status === 204 or
content-length === '0' or response.headers.get('content-type') === null and
response.bodyUsed is false/response has no body), treat those cases as undefined
result instead of asserting, and only assert/parse content-type when a body is
actually present; preserve the existing check for the X_TSS_SERIALIZED header
when present and ensure parsing flows handle both serialized and
plain/no-content responses.

// If the response is serialized by the start server, we need to process it
// differently than a normal response.
if (serializedByStart) {
let result
// If it's a stream from the start serializer, process it as such
if (contentType.includes('application/x-ndjson')) {
const refs = new Map()
result = await processServerFnResponse({
Expand All @@ -206,17 +206,22 @@ async function getResponse(fn: () => Promise<Response>) {
},
})
}
// If it's a JSON response, it can be simpler
if (contentType.includes('application/json')) {
const jsonPayload = await response.json()
result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
}

invariant(result, 'expected result to be resolved')
if (result instanceof Error) {
throw result
}

return result
}
Comment on lines 215 to 221
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: falsy results (0, '', false) are incorrectly rejected.

invariant(result, ...) rejects valid falsy values; check only undefined.

-    invariant(result, 'expected result to be resolved')
+    invariant(typeof result !== 'undefined', 'expected result to be resolved')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
invariant(result, 'expected result to be resolved')
if (result instanceof Error) {
throw result
}
return result
}
invariant(typeof result !== 'undefined', 'expected result to be resolved')
if (result instanceof Error) {
throw result
}
return result
}
🤖 Prompt for AI Agents
In packages/start-client-core/src/client-rpc/serverFnFetcher.ts around lines 215
to 221, the code uses invariant(result, 'expected result to be resolved') which
treats valid falsy values (0, '', false) as errors; change the check to only
reject undefined (e.g., replace the invariant call with a check like if (result
=== undefined) { throw new Error('expected result to be resolved') } or use
invariant(result !== undefined, ...)) so that falsy but valid results are
preserved, keep the existing Error instance handling (if (result instanceof
Error) throw result) and then return result.


// If it wasn't processed by the start serializer, check
// if it's JSON
if (contentType.includes('application/json')) {
const jsonPayload = await response.json()
const redirect = parseRedirect(jsonPayload)
Expand All @@ -229,6 +234,12 @@ async function getResponse(fn: () => Promise<Response>) {
return jsonPayload
}

// Othwerwise, if it's not OK, throw the content
if (!response.ok) {
throw new Error(await response.text())
}

// Or return the response itself
return response
}

Expand Down
1 change: 1 addition & 0 deletions packages/start-client-core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export const TSS_SERVER_FUNCTION_FACTORY = Symbol.for(

export const X_TSS_SERIALIZED = 'x-tss-serialized'
export const X_TSS_RAW_RESPONSE = 'x-tss-raw'
export const X_TSS_CONTEXT = 'x-tss-context'
export {}
Loading