Skip to content
Merged
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
38 changes: 38 additions & 0 deletions packages/react-start-client/src/tests/createServerFn.test-d.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { expectTypeOf, test } from 'vitest'
import { createServerFn } from '@tanstack/start-client-core'

/*
// disabled until we really support RSC
test.skip('createServerFn returns RSC', () => {
const fn = createServerFn().handler(() => ({
rscs: [
Expand All @@ -14,4 +16,40 @@ test.skip('createServerFn returns RSC', () => {
rscs: readonly [ReadableStream, ReadableStream]
}>
>()
})*/

test('createServerFn returns async array', () => {
const result: Array<{ a: number }> = [{ a: 1 }]
const serverFn = createServerFn({ method: 'GET' }).handler(async () => {
return result
})

expectTypeOf(serverFn()).toEqualTypeOf<Promise<Array<{ a: number }>>>()
})

test('createServerFn returns sync array', () => {
const result: Array<{ a: number }> = [{ a: 1 }]
const serverFn = createServerFn({ method: 'GET' }).handler(() => {
return result
})

expectTypeOf(serverFn()).toEqualTypeOf<Promise<Array<{ a: number }>>>()
})

test('createServerFn returns async union', () => {
const result = '1' as string | number
const serverFn = createServerFn({ method: 'GET' }).handler(async () => {
return result
})

expectTypeOf(serverFn()).toEqualTypeOf<Promise<string | number>>()
})

test('createServerFn returns sync union', () => {
const result = '1' as string | number
const serverFn = createServerFn({ method: 'GET' }).handler(() => {
return result
})

expectTypeOf(serverFn()).toEqualTypeOf<Promise<string | number>>()
})
9 changes: 8 additions & 1 deletion packages/start-client-core/src/client-rpc/serverFnFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
import { fromCrossJSON, toJSONAsync } from 'seroval'
import invariant from 'tiny-invariant'
import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins'
import { TSS_FORMDATA_CONTEXT, X_TSS_SERIALIZED } from '../constants'
import {
TSS_FORMDATA_CONTEXT,
X_TSS_RAW_RESPONSE,
X_TSS_SERIALIZED,
} from '../constants'
import type { FunctionMiddlewareClientFnOptions } from '../createMiddleware'
import type { Plugin as SerovalPlugin } from 'seroval'

Expand Down Expand Up @@ -156,6 +160,9 @@ 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)
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 @@ -5,4 +5,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 {}
50 changes: 21 additions & 29 deletions packages/start-client-core/src/createServerFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import type {
ResolveValidatorInput,
ValidateSerializable,
ValidateSerializableInput,
ValidateSerializableInputResult,
Validator,
} from '@tanstack/router-core'
import type { JsonResponse } from '@tanstack/router-core/ssr/client'
Expand Down Expand Up @@ -174,7 +173,7 @@ export async function executeMiddleware(
env: 'client' | 'server',
opts: ServerFnMiddlewareOptions,
): Promise<ServerFnMiddlewareResult> {
const globalMiddlewares = getStartOptions().functionMiddleware || []
const globalMiddlewares = getStartOptions()?.functionMiddleware || []
const flattenedMiddlewares = flattenMiddlewares([
...globalMiddlewares,
...middlewares,
Expand Down Expand Up @@ -243,10 +242,10 @@ export type CompiledFetcherFnOptions = {
context?: any
}

export type Fetcher<TRegister, TMiddlewares, TInputValidator, TResponse> =
export type Fetcher<TMiddlewares, TInputValidator, TResponse> =
undefined extends IntersectAllValidatorInputs<TMiddlewares, TInputValidator>
? OptionalFetcher<TRegister, TMiddlewares, TInputValidator, TResponse>
: RequiredFetcher<TRegister, TMiddlewares, TInputValidator, TResponse>
? OptionalFetcher<TMiddlewares, TInputValidator, TResponse>
: RequiredFetcher<TMiddlewares, TInputValidator, TResponse>

export interface FetcherBase {
[TSS_SERVER_FUNCTION]: true
Expand All @@ -260,26 +259,18 @@ export interface FetcherBase {
}) => Promise<unknown>
}

export interface OptionalFetcher<
TRegister,
TMiddlewares,
TInputValidator,
TResponse,
> extends FetcherBase {
export interface OptionalFetcher<TMiddlewares, TInputValidator, TResponse>
extends FetcherBase {
(
options?: OptionalFetcherDataOptions<TMiddlewares, TInputValidator>,
): Promise<FetcherData<TRegister, TResponse>>
): Promise<FetcherData<TResponse>>
}

export interface RequiredFetcher<
TRegister,
TMiddlewares,
TInputValidator,
TResponse,
> extends FetcherBase {
export interface RequiredFetcher<TMiddlewares, TInputValidator, TResponse>
extends FetcherBase {
(
opts: RequiredFetcherDataOptions<TMiddlewares, TInputValidator>,
): Promise<FetcherData<TRegister, TResponse>>
): Promise<FetcherData<TResponse>>
}

export type FetcherBaseOptions = {
Expand All @@ -297,22 +288,23 @@ export interface RequiredFetcherDataOptions<TMiddlewares, TInputValidator>
data: Expand<IntersectAllValidatorInputs<TMiddlewares, TInputValidator>>
}

export type FetcherData<TRegister, TResponse> = TResponse extends Response
? Response
: TResponse extends JsonResponse<any>
? ValidateSerializableInputResult<TRegister, ReturnType<TResponse['json']>>
: ValidateSerializableInputResult<TRegister, TResponse>

export type RscStream<T> = {
__cacheState: T
}

export type Method = 'GET' | 'POST'

export type FetcherData<TResponse> =
Awaited<TResponse> extends Response
? Awaited<TResponse>
: Awaited<TResponse> extends JsonResponse<any>
? ReturnType<Awaited<TResponse>['json']>
: Awaited<TResponse>

export type ServerFnReturnType<TRegister, TResponse> =
| Response
| Promise<ValidateSerializableInput<TRegister, TResponse>>
| ValidateSerializableInput<TRegister, TResponse>
Awaited<TResponse> extends Response
? TResponse
: ValidateSerializableInput<TRegister, TResponse>

export type ServerFn<
TRegister,
Expand Down Expand Up @@ -521,7 +513,7 @@ export interface ServerFnHandler<
TInputValidator,
TNewResponse
>,
) => Fetcher<TRegister, TMiddlewares, TInputValidator, TNewResponse>
) => Fetcher<TMiddlewares, TInputValidator, TNewResponse>
}

export interface ServerFnBuilder<TRegister, TMethod extends Method = 'GET'>
Expand Down
2 changes: 1 addition & 1 deletion packages/start-client-core/src/getDefaultSerovalPlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { AnySerializationAdapter } from '@tanstack/router-core'

export function getDefaultSerovalPlugins() {
const start = getStartOptions()
const adapters = start.serializationAdapters as
const adapters = start?.serializationAdapters as
| Array<AnySerializationAdapter>
| undefined
return [
Expand Down
8 changes: 5 additions & 3 deletions packages/start-client-core/src/getStartOptions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getStartContext } from '@tanstack/start-storage-context'
import { createIsomorphicFn } from './createIsomorphicFn'
import type { AnyStartInstanceOptions } from './createStart'

export const getStartOptions = createIsomorphicFn()
.client(() => window.__TSS_START_OPTIONS__!)
.server(() => getStartContext().startOptions)
export const getStartOptions: () => AnyStartInstanceOptions | undefined =
createIsomorphicFn()
.client(() => window.__TSS_START_OPTIONS__)
.server(() => getStartContext().startOptions)
1 change: 1 addition & 0 deletions packages/start-client-core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export {
TSS_FORMDATA_CONTEXT,
TSS_SERVER_FUNCTION,
X_TSS_SERIALIZED,
X_TSS_RAW_RESPONSE,
} from './constants'

export type * from './serverRoute'
Expand Down
50 changes: 45 additions & 5 deletions packages/start-client-core/src/tests/createServerFn.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,11 +317,7 @@ test('createServerFn returns undefined', () => {
test('createServerFn cannot return function', () => {
expectTypeOf(createServerFn().handler<{ func: () => 'func' }>)
.parameter(0)
.returns.toEqualTypeOf<
| Response
| { func: 'Function is not serializable' }
| Promise<{ func: 'Function is not serializable' }>
>()
.returns.toEqualTypeOf<{ func: 'Function is not serializable' }>()
})

test('createServerFn cannot validate function', () => {
Expand Down Expand Up @@ -598,3 +594,47 @@ test('createServerFn fetcher itself is serializable', () => {
const fn1 = createServerFn().handler(() => ({}))
const fn2 = createServerFn().handler(() => fn1)
})

test('createServerFn returns async Response', () => {
const serverFn = createServerFn().handler(async () => {
return new Response(new Blob([JSON.stringify({ a: 1 })]), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
})

expectTypeOf(serverFn()).toEqualTypeOf<Promise<Response>>()
})

test('createServerFn returns sync Response', () => {
const serverFn = createServerFn().handler(() => {
return new Response(new Blob([JSON.stringify({ a: 1 })]), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
})

expectTypeOf(serverFn()).toEqualTypeOf<Promise<Response>>()
})

test('createServerFn returns async array', () => {
const result: Array<{ a: number }> = [{ a: 1 }]
const serverFn = createServerFn({ method: 'GET' }).handler(async () => {
return result
})

expectTypeOf(serverFn()).toEqualTypeOf<Promise<Array<{ a: number }>>>()
})

test('createServerFn returns sync array', () => {
const result: Array<{ a: number }> = [{ a: 1 }]
const serverFn = createServerFn({ method: 'GET' }).handler(() => {
return result
})

expectTypeOf(serverFn()).toEqualTypeOf<Promise<Array<{ a: number }>>>()
})
2 changes: 2 additions & 0 deletions packages/start-server-core/src/server-functions-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isNotFound } from '@tanstack/router-core'
import invariant from 'tiny-invariant'
import {
TSS_FORMDATA_CONTEXT,
X_TSS_RAW_RESPONSE,
X_TSS_SERIALIZED,
getDefaultSerovalPlugins,
} from '@tanstack/start-client-core'
Expand Down Expand Up @@ -145,6 +146,7 @@ export const handleServerAction = async ({
// 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
Comment on lines +149 to 150
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

Setting a header on an immutable Response will throw at runtime

When developers return a Response obtained from fetch (very common for proxying upstream resources), response.headers is immutable. Calling set on it throws TypeError: Headers guard is immutable, so this path will crash before the response ever reaches the client—precisely the scenario this PR is meant to fix. Instead of mutating the existing headers, clone them (and the response metadata) into a new Response that includes X_TSS_RAW_RESPONSE.

Apply this diff to fix the issue:

-      if (result.result instanceof Response) {
-        result.result.headers.set(X_TSS_RAW_RESPONSE, 'true')
-        return result.result
-      }
+      if (result.result instanceof Response) {
+        const rawResponse = result.result
+        const headers = new Headers(rawResponse.headers)
+        headers.set(X_TSS_RAW_RESPONSE, 'true')
+        return new Response(rawResponse.body, {
+          status: rawResponse.status,
+          statusText: rawResponse.statusText,
+          headers,
+        })
+      }
📝 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
result.result.headers.set(X_TSS_RAW_RESPONSE, 'true')
return result.result
if (result.result instanceof Response) {
const rawResponse = result.result
const headers = new Headers(rawResponse.headers)
headers.set(X_TSS_RAW_RESPONSE, 'true')
return new Response(rawResponse.body, {
status: rawResponse.status,
statusText: rawResponse.statusText,
headers,
})
}
🤖 Prompt for AI Agents
In packages/start-server-core/src/server-functions-handler.ts around lines
149-150, the code attempts to call result.result.headers.set(...) on a Response
returned from fetch, but fetch Responses have immutable headers and calling set
throws at runtime; instead, create a new Response cloning the original body and
metadata and use a mutable copy of the headers: construct a new
Headers(result.result.headers), call set('X-TSS-RAW_RESPONSE','true') on that
copy, then return new Response(result.result.body, { status:
result.result.status, statusText: result.result.statusText, headers:
headersCopy, /* copy any other relevant properties like duplex if used */ });
this avoids mutating the original immutable Headers and preserves the original
response metadata and body.

}

Expand Down
Loading