Skip to content

Commit 767e4a9

Browse files
refactor: only handle createServerFn (#6226)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 5463487 commit 767e4a9

File tree

2 files changed

+89
-147
lines changed

2 files changed

+89
-147
lines changed

packages/start-client-core/src/client-rpc/serverFnFetcher.ts

Lines changed: 61 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
encode,
3-
isNotFound,
4-
isPlainObject,
5-
parseRedirect,
6-
} from '@tanstack/router-core'
1+
import { encode, isNotFound, parseRedirect } from '@tanstack/router-core'
72
import { fromCrossJSON, toJSONAsync } from 'seroval'
83
import invariant from 'tiny-invariant'
94
import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins'
@@ -17,6 +12,20 @@ import type { Plugin as SerovalPlugin } from 'seroval'
1712

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

15+
/**
16+
* Checks if an object has at least one own enumerable property.
17+
* More efficient than Object.keys(obj).length > 0 as it short-circuits on first property.
18+
*/
19+
const hop = Object.prototype.hasOwnProperty
20+
function hasOwnProperties(obj: object): boolean {
21+
for (const _ in obj) {
22+
if (hop.call(obj, _)) {
23+
return true
24+
}
25+
}
26+
return false
27+
}
28+
2029
export async function serverFnFetcher(
2130
url: string,
2231
args: Array<any>,
@@ -27,80 +36,52 @@ export async function serverFnFetcher(
2736
}
2837
const _first = args[0]
2938

30-
// If createServerFn was used to wrap the fetcher,
31-
// We need to handle the arguments differently
32-
if (isPlainObject(_first) && _first.method) {
33-
const first = _first as FunctionMiddlewareClientFnOptions<any, any, any> & {
34-
headers: HeadersInit
35-
}
36-
const type = first.data instanceof FormData ? 'formData' : 'payload'
37-
38-
// Arrange the headers
39-
const headers = new Headers({
40-
'x-tsr-redirect': 'manual',
41-
...(first.headers instanceof Headers
42-
? Object.fromEntries(first.headers.entries())
43-
: first.headers),
44-
})
39+
const first = _first as FunctionMiddlewareClientFnOptions<any, any, any> & {
40+
headers?: HeadersInit
41+
}
42+
const type = first.data instanceof FormData ? 'formData' : 'payload'
4543

46-
if (type === 'payload') {
47-
headers.set('accept', 'application/x-ndjson, application/json')
48-
}
44+
// Arrange the headers
45+
const headers = first.headers ? new Headers(first.headers) : new Headers()
46+
headers.set('x-tsr-redirect', 'manual')
4947

50-
// If the method is GET, we need to move the payload to the query string
51-
if (first.method === 'GET') {
52-
if (type === 'formData') {
53-
throw new Error('FormData is not supported with GET requests')
54-
}
55-
const serializedPayload = await serializePayload(first)
56-
if (serializedPayload !== undefined) {
57-
const encodedPayload = encode({
58-
payload: await serializePayload(first),
59-
})
60-
if (url.includes('?')) {
61-
url += `&${encodedPayload}`
62-
} else {
63-
url += `?${encodedPayload}`
64-
}
65-
}
66-
}
48+
if (type === 'payload') {
49+
headers.set('accept', 'application/x-ndjson, application/json')
50+
}
6751

68-
if (url.includes('?')) {
69-
url += `&createServerFn`
70-
} else {
71-
url += `?createServerFn`
52+
// If the method is GET, we need to move the payload to the query string
53+
if (first.method === 'GET') {
54+
if (type === 'formData') {
55+
throw new Error('FormData is not supported with GET requests')
7256
}
73-
74-
let body = undefined
75-
if (first.method === 'POST') {
76-
const fetchBody = await getFetchBody(first)
77-
if (fetchBody?.contentType) {
78-
headers.set('content-type', fetchBody.contentType)
57+
const serializedPayload = await serializePayload(first)
58+
if (serializedPayload !== undefined) {
59+
const encodedPayload = encode({
60+
payload: serializedPayload,
61+
})
62+
if (url.includes('?')) {
63+
url += `&${encodedPayload}`
64+
} else {
65+
url += `?${encodedPayload}`
7966
}
80-
body = fetchBody?.body
8167
}
68+
}
8269

83-
return await getResponse(async () =>
84-
handler(url, {
85-
method: first.method,
86-
headers,
87-
signal: first.signal,
88-
body,
89-
}),
90-
)
70+
let body = undefined
71+
if (first.method === 'POST') {
72+
const fetchBody = await getFetchBody(first)
73+
if (fetchBody?.contentType) {
74+
headers.set('content-type', fetchBody.contentType)
75+
}
76+
body = fetchBody?.body
9177
}
9278

93-
// If not a custom fetcher, it was probably
94-
// a `use server` function, so just proxy the arguments
95-
// through as a POST request
96-
return await getResponse(() =>
79+
return await getResponse(async () =>
9780
handler(url, {
98-
method: 'POST',
99-
headers: {
100-
Accept: 'application/json',
101-
'Content-Type': 'application/json',
102-
},
103-
body: JSON.stringify(args),
81+
method: first.method,
82+
headers,
83+
signal: first.signal,
84+
body,
10485
}),
10586
)
10687
}
@@ -116,7 +97,7 @@ async function serializePayload(
11697
}
11798

11899
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
119-
if (opts.context && Object.keys(opts.context).length > 0) {
100+
if (opts.context && hasOwnProperties(opts.context)) {
120101
payloadAvailable = true
121102
payloadToSerialize['context'] = opts.context
122103
}
@@ -139,7 +120,7 @@ async function getFetchBody(
139120
if (opts.data instanceof FormData) {
140121
let serializedContext = undefined
141122
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
142-
if (opts.context && Object.keys(opts.context).length > 0) {
123+
if (opts.context && hasOwnProperties(opts.context)) {
143124
serializedContext = await serialize(opts.context)
144125
}
145126
if (serializedContext !== undefined) {
@@ -163,17 +144,17 @@ async function getFetchBody(
163144
* @throws If the response is invalid or an error occurs during processing.
164145
*/
165146
async function getResponse(fn: () => Promise<Response>) {
166-
const response = await (async () => {
167-
try {
168-
return await fn()
169-
} catch (error) {
170-
if (error instanceof Response) {
171-
return error
172-
}
147+
let response: Response
148+
try {
149+
response = await fn()
150+
} catch (error) {
151+
if (error instanceof Response) {
152+
response = error
153+
} else {
173154
console.log(error)
174155
throw error
175156
}
176-
})()
157+
}
177158

178159
if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') {
179160
return response

packages/start-server-core/src/server-functions-handler.ts

Lines changed: 28 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,19 @@ import {
99
import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'
1010
import { getResponse } from './request-response'
1111
import { getServerFnById } from './getServerFnById'
12+
import type { Plugin as SerovalPlugin } from 'seroval'
1213

1314
let regex: RegExp | undefined = undefined
1415

16+
// Cache serovalPlugins at module level to avoid repeated calls
17+
let serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined
18+
19+
// Known FormData 'Content-Type' header values - module-level constant
20+
const FORM_DATA_CONTENT_TYPES = [
21+
'multipart/form-data',
22+
'application/x-www-form-urlencoded',
23+
]
24+
1525
export const handleServerAction = async ({
1626
request,
1727
context,
@@ -29,34 +39,27 @@ export const handleServerAction = async ({
2939
}
3040

3141
const method = request.method
42+
const methodLower = method.toLowerCase()
3243
const url = new URL(request.url, 'http://localhost:3000')
3344
// extract the serverFnId from the url as host/_serverFn/:serverFnId
3445
// Define a regex to match the path and extract the :thing part
3546

3647
// Execute the regex
3748
const match = url.pathname.match(regex)
3849
const serverFnId = match ? match[1] : null
39-
const search = Object.fromEntries(url.searchParams.entries()) as {
40-
payload?: any
41-
createServerFn?: boolean
42-
}
43-
44-
const isCreateServerFn = 'createServerFn' in search
4550

4651
if (typeof serverFnId !== 'string') {
4752
throw new Error('Invalid server action param for serverFnId: ' + serverFnId)
4853
}
4954

5055
const action = await getServerFnById(serverFnId, { fromClient: true })
5156

52-
// Known FormData 'Content-Type' header values
53-
const formDataContentTypes = [
54-
'multipart/form-data',
55-
'application/x-www-form-urlencoded',
56-
]
57+
// Initialize serovalPlugins lazily (cached at module level)
58+
if (!serovalPlugins) {
59+
serovalPlugins = getDefaultSerovalPlugins()
60+
}
5761

5862
const contentType = request.headers.get('Content-Type')
59-
const serovalPlugins = getDefaultSerovalPlugins()
6063

6164
function parsePayload(payload: any) {
6265
const parsedPayload = fromJSON(payload, { plugins: serovalPlugins })
@@ -65,16 +68,16 @@ export const handleServerAction = async ({
6568

6669
const response = await (async () => {
6770
try {
68-
let result = await (async () => {
71+
const result = await (async () => {
6972
// FormData
7073
if (
71-
formDataContentTypes.some(
74+
FORM_DATA_CONTENT_TYPES.some(
7275
(type) => contentType && contentType.includes(type),
7376
)
7477
) {
7578
// We don't support GET requests with FormData payloads... that seems impossible
7679
invariant(
77-
method.toLowerCase() !== 'get',
80+
methodLower !== 'get',
7881
'GET requests with FormData payloads are not supported',
7982
)
8083
const formData = await request.formData()
@@ -104,21 +107,19 @@ export const handleServerAction = async ({
104107
}
105108

106109
// Get requests use the query string
107-
if (method.toLowerCase() === 'get') {
108-
invariant(
109-
isCreateServerFn,
110-
'expected GET request to originate from createServerFn',
111-
)
112-
// By default the payload is the search params
113-
let payload: any = search.payload
110+
if (methodLower === 'get') {
111+
// Get payload directly from searchParams
112+
const payloadParam = url.searchParams.get('payload')
114113
// If there's a payload, we should try to parse it
115-
payload = payload ? parsePayload(JSON.parse(payload)) : {}
114+
const payload: any = payloadParam
115+
? parsePayload(JSON.parse(payloadParam))
116+
: {}
116117
payload.context = { ...context, ...payload.context }
117118
// Send it through!
118119
return await action(payload, signal)
119120
}
120121

121-
if (method.toLowerCase() !== 'post') {
122+
if (methodLower !== 'post') {
122123
throw new Error('expected POST method')
123124
}
124125

@@ -127,18 +128,9 @@ export const handleServerAction = async ({
127128
jsonPayload = await request.json()
128129
}
129130

130-
// If this POST request was created by createServerFn,
131-
// its payload will be the only argument
132-
if (isCreateServerFn) {
133-
const payload = jsonPayload ? parsePayload(jsonPayload) : {}
134-
payload.context = { ...payload.context, ...context }
135-
return await action(payload, signal)
136-
}
137-
138-
// Otherwise, we'll spread the payload. Need to
139-
// support `use server` functions that take multiple
140-
// arguments.
141-
return await action(...jsonPayload)
131+
const payload = jsonPayload ? parsePayload(jsonPayload) : {}
132+
payload.context = { ...payload.context, ...context }
133+
return await action(payload, signal)
142134
})()
143135

144136
// Any time we get a Response back, we should just
@@ -148,37 +140,6 @@ export const handleServerAction = async ({
148140
return result.result
149141
}
150142

151-
// If this is a non createServerFn request, we need to
152-
// pull out the result from the result object
153-
if (!isCreateServerFn) {
154-
result = result.result
155-
156-
// The result might again be a response,
157-
// and if it is, return it.
158-
if (result instanceof Response) {
159-
return result
160-
}
161-
}
162-
163-
// TODO: RSCs Where are we getting this package?
164-
// if (isValidElement(result)) {
165-
// const { renderToPipeableStream } = await import(
166-
// // @ts-expect-error
167-
// 'react-server-dom/server'
168-
// )
169-
170-
// const pipeableStream = renderToPipeableStream(result)
171-
172-
// setHeaders(event, {
173-
// 'Content-Type': 'text/x-component',
174-
// } as any)
175-
176-
// sendStream(event, response)
177-
// event._handled = true
178-
179-
// return new Response(null, { status: 200 })
180-
// }
181-
182143
if (isNotFound(result)) {
183144
return isNotFoundResponse(result)
184145
}

0 commit comments

Comments
 (0)