From ce2f9fa533a96561f33136127b16ad3638a532c0 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 17 Oct 2025 14:15:56 -0600 Subject: [PATCH 1/3] fix: refactor middleware --- .../server-functions/src/routeTree.gen.ts | 22 ++++ .../server-functions/src/routes/index.tsx | 5 + .../routes/middleware/unhandled-exception.tsx | 33 +++++ .../start-client-core/src/createServerFn.ts | 113 ++++++++++-------- packages/start-client-core/src/index.tsx | 1 - 5 files changed, 120 insertions(+), 54 deletions(-) create mode 100644 e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index b5692de059..73ad1c262f 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -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' @@ -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', @@ -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 @@ -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 @@ -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 @@ -248,6 +258,7 @@ export interface FileRouteTypes { | '/middleware/client-middleware-router' | '/middleware/request-middleware' | '/middleware/send-serverFn' + | '/middleware/unhandled-exception' | '/cookies' | '/factory' | '/formdata-redirect' @@ -273,6 +284,7 @@ export interface FileRouteTypes { | '/middleware/client-middleware-router' | '/middleware/request-middleware' | '/middleware/send-serverFn' + | '/middleware/unhandled-exception' | '/cookies' | '/factory' | '/formdata-redirect' @@ -298,6 +310,7 @@ export interface FileRouteTypes { | '/middleware/client-middleware-router' | '/middleware/request-middleware' | '/middleware/send-serverFn' + | '/middleware/unhandled-exception' | '/cookies/' | '/factory/' | '/formdata-redirect/' @@ -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 @@ -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' @@ -516,6 +537,7 @@ const rootRouteChildren: RootRouteChildren = { MiddlewareClientMiddlewareRouterRoute: MiddlewareClientMiddlewareRouterRoute, MiddlewareRequestMiddlewareRoute: MiddlewareRequestMiddlewareRoute, MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute, + MiddlewareUnhandledExceptionRoute: MiddlewareUnhandledExceptionRoute, CookiesIndexRoute: CookiesIndexRoute, FactoryIndexRoute: FactoryIndexRoute, FormdataRedirectIndexRoute: FormdataRedirectIndexRoute, diff --git a/e2e/react-start/server-functions/src/routes/index.tsx b/e2e/react-start/server-functions/src/routes/index.tsx index 596b855c09..ca1394015a 100644 --- a/e2e/react-start/server-functions/src/routes/index.tsx +++ b/e2e/react-start/server-functions/src/routes/index.tsx @@ -88,6 +88,11 @@ function Home() {
  • Server Functions Factory E2E tests
  • +
  • + + Server Functions Middleware Unhandled Exception E2E tests + +
  • ) diff --git a/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx b/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx new file mode 100644 index 0000000000..f6d3bb6017 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx @@ -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 ( +
    + {person.name} - {person.randomNumber} +
    + ) +} diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index bc95a6b5b9..a120dafa67 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,4 +1,3 @@ -import { isNotFound, isRedirect } from '@tanstack/router-core' import { mergeHeaders } from '@tanstack/router-core/ssr/client' import { TSS_SERVER_FUNCTION_FACTORY } from './constants' @@ -112,17 +111,17 @@ export const createServerFn: CreateServerFn = (options, __opts) => { return Object.assign( async (opts?: CompiledFetcherFnOptions) => { // Start by executing the client-side middleware chain - return executeMiddleware(resolvedMiddleware, 'client', { + const result = await executeMiddleware(resolvedMiddleware, 'client', { ...extractedFn, ...newOptions, data: opts?.data as any, headers: opts?.headers, signal: opts?.signal, context: {}, - }).then((d) => { - if (d.error) throw d.error - return d.result }) + + if (result.error) throw result.error + return result.result }, { // This copies over the URL, function ID @@ -180,7 +179,7 @@ export async function executeMiddleware( ...middlewares, ]) - const next: NextFn = async (ctx) => { + const callNextMiddleware: NextFn = async (ctx) => { // Get the next middleware const nextMiddleware = flattenedMiddlewares.shift() @@ -212,27 +211,70 @@ export async function executeMiddleware( middlewareFn = nextMiddleware.options.server as MiddlewareFn | undefined } - if (middlewareFn) { - // Execute the middleware - return applyMiddleware(middlewareFn, ctx, async (newCtx) => { - return next(newCtx).catch((error: any) => { - if (isRedirect(error) || isNotFound(error)) { + // Execute the middleware + try { + if (middlewareFn) { + const userNext = async ( + userCtx: ServerFnMiddlewareResult | undefined = {} as any, + ) => { + // Return the next middleware + const nextCtx = { + ...ctx, + ...userCtx, + context: { + ...ctx.context, + ...userCtx.context, + }, + sendContext: { + ...ctx.sendContext, + ...(userCtx.sendContext ?? {}), + }, + headers: mergeHeaders(ctx.headers, userCtx.headers), + result: + userCtx.result !== undefined + ? userCtx.result + : userCtx instanceof Response + ? userCtx + : (ctx as any).result, + error: userCtx.error ?? (ctx as any).error, + } + + try { + return await callNextMiddleware(nextCtx) + } catch (error: any) { return { - ...newCtx, + ...nextCtx, error, } } + } - throw error - }) - }) - } + // Execute the middleware + const result = await middlewareFn({ + ...ctx, + next: userNext as any, + } as any) + + if (!(result as any)) { + throw new Error( + 'User middleware returned undefined. You must call next() or return a result in your middlewares.', + ) + } + + return result + } - return next(ctx) + return callNextMiddleware(ctx) + } catch (error: any) { + return { + ...ctx, + error, + } + } } // Start the middleware chain - return next({ + return callNextMiddleware({ ...opts, headers: opts.headers || {}, sendContext: opts.sendContext || {}, @@ -628,41 +670,6 @@ export type MiddlewareFn = ( }, ) => Promise -export const applyMiddleware = async ( - middlewareFn: MiddlewareFn, - ctx: ServerFnMiddlewareOptions, - nextFn: NextFn, -) => { - return middlewareFn({ - ...ctx, - next: (async ( - userCtx: ServerFnMiddlewareResult | undefined = {} as any, - ) => { - // Return the next middleware - return nextFn({ - ...ctx, - ...userCtx, - context: { - ...ctx.context, - ...userCtx.context, - }, - sendContext: { - ...ctx.sendContext, - ...(userCtx.sendContext ?? {}), - }, - headers: mergeHeaders(ctx.headers, userCtx.headers), - result: - userCtx.result !== undefined - ? userCtx.result - : userCtx instanceof Response - ? userCtx - : (ctx as any).result, - error: userCtx.error ?? (ctx as any).error, - }) - }) as any, - } as any) -} - export function execValidator( validator: AnyValidator, input: unknown, diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index 70a4692842..9ba2eeaa71 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -74,7 +74,6 @@ export type { RequiredFetcher, } from './createServerFn' export { - applyMiddleware, execValidator, flattenMiddlewares, executeMiddleware, From ed548799d38abe1a73c9ed3f9005833cec06fe35 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 17 Oct 2025 14:52:25 -0600 Subject: [PATCH 2/3] fix: catch errors from validators --- .../start-client-core/src/createServerFn.ts | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index a120dafa67..0fecefcd50 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -188,31 +188,33 @@ export async function executeMiddleware( return ctx } - if ( - 'inputValidator' in nextMiddleware.options && - nextMiddleware.options.inputValidator && - env === 'server' - ) { - // Execute the middleware's input function - ctx.data = await execValidator( - nextMiddleware.options.inputValidator, - ctx.data, - ) - } + // Execute the middleware + try { + if ( + 'inputValidator' in nextMiddleware.options && + nextMiddleware.options.inputValidator && + env === 'server' + ) { + // Execute the middleware's input function + ctx.data = await execValidator( + nextMiddleware.options.inputValidator, + ctx.data, + ) + } - let middlewareFn: MiddlewareFn | undefined = undefined - if (env === 'client') { - if ('client' in nextMiddleware.options) { - middlewareFn = nextMiddleware.options.client as MiddlewareFn | undefined + let middlewareFn: MiddlewareFn | undefined = undefined + if (env === 'client') { + if ('client' in nextMiddleware.options) { + middlewareFn = nextMiddleware.options.client as + | MiddlewareFn + | undefined + } + } + // env === 'server' + else if ('server' in nextMiddleware.options) { + middlewareFn = nextMiddleware.options.server as MiddlewareFn | undefined } - } - // env === 'server' - else if ('server' in nextMiddleware.options) { - middlewareFn = nextMiddleware.options.server as MiddlewareFn | undefined - } - // Execute the middleware - try { if (middlewareFn) { const userNext = async ( userCtx: ServerFnMiddlewareResult | undefined = {} as any, From c7eddee32cba07565624c5f3b18f89e7a6af7efb Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 27 Oct 2025 10:59:43 -0700 Subject: [PATCH 3/3] checkpoint --- .../src/routes/submit-post-formdata.tsx | 2 +- .../src/client-rpc/serverFnFetcher.ts | 47 ++-- packages/start-client-core/src/constants.ts | 1 + .../start-client-core/src/createServerFn.ts | 44 +++- packages/start-client-core/src/index.tsx | 1 + .../src/createStartHandler.ts | 7 +- .../src/server-functions-handler.ts | 234 ++++++++++-------- 7 files changed, 204 insertions(+), 132 deletions(-) diff --git a/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx b/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx index 826ec255d3..5f9165adf7 100644 --- a/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx +++ b/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx @@ -33,7 +33,7 @@ function SubmitPostFormDataFn() {

    Submit POST FormData Fn Call

    - It should return navigate and return{' '} + It should navigate to a raw response of {''}
                 Hello, {testValues.name}!
    diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    index 5511bdc517..b1b781d7d0 100644
    --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    @@ -17,6 +17,16 @@ import type { Plugin as SerovalPlugin } from 'seroval'
     
     let serovalPlugins: Array> | null = null
     
    +// caller =>
    +//   serverFnFetcher =>
    +//     client =>
    +//       server =>
    +//         fn =>
    +//       seroval =>
    +//     client middleware =>
    +//   serverFnFetcher =>
    +// caller
    +
     export async function serverFnFetcher(
       url: string,
       args: Array,
    @@ -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),
    @@ -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)
    @@ -97,6 +102,7 @@ export async function serverFnFetcher(
         handler(url, {
           method: 'POST',
           headers: {
    +        'x-tsr-serverFn': 'true',
             Accept: 'application/json',
             'Content-Type': 'application/json',
           },
    @@ -165,7 +171,7 @@ async function getFetchBody(
     async function getResponse(fn: () => Promise) {
       const response = await (async () => {
         try {
    -      return await fn()
    +      return await fn() // client => server => fn => server => client
         } catch (error) {
           if (error instanceof Response) {
             return error
    @@ -178,22 +184,16 @@ async function getResponse(fn: () => Promise) {
       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())
    -  }
     
    +  // 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({
    @@ -206,17 +206,22 @@ async function getResponse(fn: () => Promise) {
             },
           })
         }
    +    // 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
       }
     
    +  // 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)
    @@ -229,6 +234,12 @@ async function getResponse(fn: () => Promise) {
         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
     }
     
    diff --git a/packages/start-client-core/src/constants.ts b/packages/start-client-core/src/constants.ts
    index 1e541af323..4e0777068d 100644
    --- a/packages/start-client-core/src/constants.ts
    +++ b/packages/start-client-core/src/constants.ts
    @@ -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 {}
    diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts
    index 0fecefcd50..b472d88d79 100644
    --- a/packages/start-client-core/src/createServerFn.ts
    +++ b/packages/start-client-core/src/createServerFn.ts
    @@ -1,9 +1,9 @@
     import { mergeHeaders } from '@tanstack/router-core/ssr/client'
     
    +import { isRedirect, parseRedirect } from '@tanstack/router-core'
     import { TSS_SERVER_FUNCTION_FACTORY } from './constants'
     import { getStartOptions } from './getStartOptions'
     import { getStartContextServerOnly } from './getStartContextServerOnly'
    -import type { TSS_SERVER_FUNCTION } from './constants'
     import type {
       AnyValidator,
       Constrain,
    @@ -15,6 +15,7 @@ import type {
       ValidateSerializableInput,
       Validator,
     } from '@tanstack/router-core'
    +import type { TSS_SERVER_FUNCTION } from './constants'
     import type {
       AnyFunctionMiddleware,
       AnyRequestMiddleware,
    @@ -120,6 +121,11 @@ export const createServerFn: CreateServerFn = (options, __opts) => {
                 context: {},
               })
     
    +          const redirect = parseRedirect(result.error)
    +          if (redirect) {
    +            throw redirect
    +          }
    +
               if (result.error) throw result.error
               return result.result
             },
    @@ -143,14 +149,18 @@ export const createServerFn: CreateServerFn = (options, __opts) => {
                   request: startContext.request,
                 }
     
    -            return executeMiddleware(resolvedMiddleware, 'server', ctx).then(
    -              (d) => ({
    -                // Only send the result and sendContext back to the client
    -                result: d.result,
    -                error: d.error,
    -                context: d.sendContext,
    -              }),
    -            )
    +            const result = await executeMiddleware(
    +              resolvedMiddleware,
    +              'server',
    +              ctx,
    +            ).then((d) => ({
    +              // Only send the result and sendContext back to the client
    +              result: d.result,
    +              error: d.error,
    +              context: d.sendContext,
    +            }))
    +
    +            return result
               },
             },
           ) as any
    @@ -257,6 +267,22 @@ export async function executeMiddleware(
               next: userNext as any,
             } as any)
     
    +        // If result is NOT a ctx object, we need to return it as
    +        // the { result }
    +        if (isRedirect(result)) {
    +          return {
    +            ...ctx,
    +            error: result,
    +          }
    +        }
    +
    +        if (result instanceof Response) {
    +          return {
    +            ...ctx,
    +            result,
    +          }
    +        }
    +
             if (!(result as any)) {
               throw new Error(
                 'User middleware returned undefined. You must call next() or return a result in your middlewares.',
    diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx
    index 9ba2eeaa71..10a84f299b 100644
    --- a/packages/start-client-core/src/index.tsx
    +++ b/packages/start-client-core/src/index.tsx
    @@ -84,6 +84,7 @@ export {
       TSS_SERVER_FUNCTION,
       X_TSS_SERIALIZED,
       X_TSS_RAW_RESPONSE,
    +  X_TSS_CONTEXT,
     } from './constants'
     
     export type * from './serverRoute'
    diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts
    index d0fd84e8f4..ff7bd0f445 100644
    --- a/packages/start-server-core/src/createStartHandler.ts
    +++ b/packages/start-server-core/src/createStartHandler.ts
    @@ -268,16 +268,17 @@ export function createStartHandler(
           [...middlewares, requestHandlerMiddleware],
           {
             request,
    -
             context: requestOpts?.context || {},
           },
         )
     
         const response: Response = ctx.response
     
    +    console.log('response', response)
    +
         if (isRedirect(response)) {
           if (isResolvedRedirect(response)) {
    -        if (request.headers.get('x-tsr-redirect') === 'manual') {
    +        if (request.headers.get('x-tsr-createServerFn') === 'true') {
               return json(
                 {
                   ...response.options,
    @@ -318,7 +319,7 @@ export function createStartHandler(
           const router = await getRouter()
           const redirect = router.resolveRedirect(response)
     
    -      if (request.headers.get('x-tsr-redirect') === 'manual') {
    +      if (request.headers.get('x-tsr-createServerFn') === 'true') {
             return json(
               {
                 ...response.options,
    diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts
    index ec9042ec47..221ca5af00 100644
    --- a/packages/start-server-core/src/server-functions-handler.ts
    +++ b/packages/start-server-core/src/server-functions-handler.ts
    @@ -1,10 +1,11 @@
    -import { isNotFound } from '@tanstack/router-core'
    +import { isNotFound, isPlainObject } from '@tanstack/router-core'
     import invariant from 'tiny-invariant'
     import {
       TSS_FORMDATA_CONTEXT,
       X_TSS_RAW_RESPONSE,
       X_TSS_SERIALIZED,
       getDefaultSerovalPlugins,
    +  json,
     } from '@tanstack/start-client-core'
     import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'
     import { getResponse } from './request-response'
    @@ -41,7 +42,9 @@ export const handleServerAction = async ({
         createServerFn?: boolean
       }
     
    -  const isCreateServerFn = 'createServerFn' in search
    +  const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'
    +  const isCreateServerFn =
    +    request.headers.get('x-tsr-createServerFn') === 'true'
     
       if (typeof serverFnId !== 'string') {
         throw new Error('Invalid server action param for serverFnId: ' + serverFnId)
    @@ -56,6 +59,9 @@ export const handleServerAction = async ({
       ]
     
       const contentType = request.headers.get('Content-Type')
    +  const isFormData = formDataContentTypes.some(
    +    (type) => contentType && contentType.includes(type),
    +  )
       const serovalPlugins = getDefaultSerovalPlugins()
     
       function parsePayload(payload: any) {
    @@ -65,13 +71,9 @@ export const handleServerAction = async ({
     
       const response = await (async () => {
         try {
    -      let result = await (async () => {
    +      let res = await (async () => {
             // FormData
    -        if (
    -          formDataContentTypes.some(
    -            (type) => contentType && contentType.includes(type),
    -          )
    -        ) {
    +        if (isFormData) {
               // We don't support GET requests with FormData payloads... that seems impossible
               invariant(
                 method.toLowerCase() !== 'get',
    @@ -135,25 +137,55 @@ export const handleServerAction = async ({
             return await action(...jsonPayload)
           })()
     
    -      // Any time we get a Response back, we should just
    -      // return it immediately.
    -      if (result.result instanceof Response) {
    -        result.result.headers.set(X_TSS_RAW_RESPONSE, 'true')
    -        return result.result
    -      }
    +      const isCtxResult =
    +        isPlainObject(res) &&
    +        'context' in res &&
    +        ('result' in res || 'error' in res)
    +
    +      console.log(
    +        {
    +          isServerFn,
    +          isCreateServerFn,
    +          isFormData,
    +          isCtxResult,
    +        },
    +        res,
    +      )
     
    -      // If this is a non createServerFn request, we need to
    -      // pull out the result from the result object
    -      if (!isCreateServerFn) {
    -        result = result.result
    +      function unwrapResultOrError(result: any) {
    +        if (
    +          isPlainObject(result) &&
    +          ('result' in result || 'error' in result)
    +        ) {
    +          console.log('tanner')
    +          return result.result || result.error
    +        }
    +        return result
    +      }
     
    -        // The result might again be a response,
    -        // and if it is, return it.
    -        if (result instanceof Response) {
    -          return result
    +      // This was not called by the serverFnFetcher, so it's likely a no-JS POST request)
    +      if (isCtxResult) {
    +        const unwrapped = unwrapResultOrError(res)
    +        if (unwrapped instanceof Response) {
    +          res = unwrapped
    +        } else {
    +          res = json(unwrapped)
             }
           }
     
    +      if (isNotFound(res)) {
    +        res = isNotFoundResponse(res)
    +      }
    +
    +      if (!isServerFn) {
    +        return res
    +      }
    +
    +      if (res instanceof Response) {
    +        res.headers.set(X_TSS_RAW_RESPONSE, 'true')
    +        return res
    +      }
    +
           // TODO: RSCs Where are we getting this package?
           // if (isValidElement(result)) {
           //   const { renderToPipeableStream } = await import(
    @@ -173,91 +205,91 @@ export const handleServerAction = async ({
           //   return new Response(null, { status: 200 })
           // }
     
    -      if (isNotFound(result)) {
    -        return isNotFoundResponse(result)
    -      }
    -
    -      const response = getResponse()
    -      let nonStreamingBody: any = undefined
    -
    -      if (result !== undefined) {
    -        // first run without the stream in case `result` does not need streaming
    -        let done = false as boolean
    -        const callbacks: {
    -          onParse: (value: any) => void
    -          onDone: () => void
    -          onError: (error: any) => void
    -        } = {
    -          onParse: (value) => {
    -            nonStreamingBody = value
    -          },
    -          onDone: () => {
    -            done = true
    -          },
    -          onError: (error) => {
    -            throw error
    -          },
    -        }
    -        toCrossJSONStream(result, {
    -          refs: new Map(),
    -          plugins: serovalPlugins,
    -          onParse(value) {
    -            callbacks.onParse(value)
    -          },
    -          onDone() {
    -            callbacks.onDone()
    -          },
    -          onError: (error) => {
    -            callbacks.onError(error)
    -          },
    -        })
    -        if (done) {
    -          return new Response(
    -            nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
    -            {
    -              status: response?.status,
    -              statusText: response?.statusText,
    -              headers: {
    -                'Content-Type': 'application/json',
    -                [X_TSS_SERIALIZED]: 'true',
    +      return serializeResult(res)
    +
    +      function serializeResult(res: unknown): Response {
    +        let nonStreamingBody: any = undefined
    +
    +        const alsResponse = getResponse()
    +        if (res !== undefined) {
    +          // first run without the stream in case `result` does not need streaming
    +          let done = false as boolean
    +          const callbacks: {
    +            onParse: (value: any) => void
    +            onDone: () => void
    +            onError: (error: any) => void
    +          } = {
    +            onParse: (value) => {
    +              nonStreamingBody = value
    +            },
    +            onDone: () => {
    +              done = true
    +            },
    +            onError: (error) => {
    +              throw error
    +            },
    +          }
    +          toCrossJSONStream(res, {
    +            refs: new Map(),
    +            plugins: serovalPlugins,
    +            onParse(value) {
    +              callbacks.onParse(value)
    +            },
    +            onDone() {
    +              callbacks.onDone()
    +            },
    +            onError: (error) => {
    +              callbacks.onError(error)
    +            },
    +          })
    +          if (done) {
    +            return new Response(
    +              nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
    +              {
    +                status: alsResponse?.status,
    +                statusText: alsResponse?.statusText,
    +                headers: {
    +                  'Content-Type': 'application/json',
    +                  [X_TSS_SERIALIZED]: 'true',
    +                },
                   },
    +            )
    +          }
    +
    +          // not done yet, we need to stream
    +          const stream = new ReadableStream({
    +            start(controller) {
    +              callbacks.onParse = (value) =>
    +                controller.enqueue(JSON.stringify(value) + '\n')
    +              callbacks.onDone = () => {
    +                try {
    +                  controller.close()
    +                } catch (error) {
    +                  controller.error(error)
    +                }
    +              }
    +              callbacks.onError = (error) => controller.error(error)
    +              // stream the initial body
    +              if (nonStreamingBody !== undefined) {
    +                callbacks.onParse(nonStreamingBody)
    +              }
                 },
    -          )
    +          })
    +          return new Response(stream, {
    +            status: alsResponse?.status,
    +            statusText: alsResponse?.statusText,
    +            headers: {
    +              'Content-Type': 'application/x-ndjson',
    +              [X_TSS_SERIALIZED]: 'true',
    +            },
    +          })
             }
     
    -        // not done yet, we need to stream
    -        const stream = new ReadableStream({
    -          start(controller) {
    -            callbacks.onParse = (value) =>
    -              controller.enqueue(JSON.stringify(value) + '\n')
    -            callbacks.onDone = () => {
    -              try {
    -                controller.close()
    -              } catch (error) {
    -                controller.error(error)
    -              }
    -            }
    -            callbacks.onError = (error) => controller.error(error)
    -            // stream the initial body
    -            if (nonStreamingBody !== undefined) {
    -              callbacks.onParse(nonStreamingBody)
    -            }
    -          },
    -        })
    -        return new Response(stream, {
    -          status: response?.status,
    -          statusText: response?.statusText,
    -          headers: {
    -            'Content-Type': 'application/x-ndjson',
    -            [X_TSS_SERIALIZED]: 'true',
    -          },
    +        return new Response(undefined, {
    +          status: alsResponse?.status,
    +          statusText: alsResponse?.statusText,
             })
           }
    -
    -      return new Response(undefined, {
    -        status: response?.status,
    -        statusText: response?.statusText,
    -      })
         } catch (error: any) {
           if (error instanceof Response) {
             return error