diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 5511bdc5173..4cf9ac381ce 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -1,9 +1,4 @@ -import { - encode, - isNotFound, - isPlainObject, - parseRedirect, -} from '@tanstack/router-core' +import { encode, isNotFound, parseRedirect } from '@tanstack/router-core' import { fromCrossJSON, toJSONAsync } from 'seroval' import invariant from 'tiny-invariant' import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins' @@ -17,6 +12,20 @@ import type { Plugin as SerovalPlugin } from 'seroval' let serovalPlugins: Array> | null = null +/** + * Checks if an object has at least one own enumerable property. + * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property. + */ +const hop = Object.prototype.hasOwnProperty +function hasOwnProperties(obj: object): boolean { + for (const _ in obj) { + if (hop.call(obj, _)) { + return true + } + } + return false +} + export async function serverFnFetcher( url: string, args: Array, @@ -27,80 +36,52 @@ export async function serverFnFetcher( } const _first = args[0] - // If createServerFn was used to wrap the fetcher, - // We need to handle the arguments differently - if (isPlainObject(_first) && _first.method) { - const first = _first as FunctionMiddlewareClientFnOptions & { - headers: HeadersInit - } - const type = first.data instanceof FormData ? 'formData' : 'payload' - - // Arrange the headers - const headers = new Headers({ - 'x-tsr-redirect': 'manual', - ...(first.headers instanceof Headers - ? Object.fromEntries(first.headers.entries()) - : first.headers), - }) + const first = _first as FunctionMiddlewareClientFnOptions & { + headers?: HeadersInit + } + const type = first.data instanceof FormData ? 'formData' : 'payload' - if (type === 'payload') { - headers.set('accept', 'application/x-ndjson, application/json') - } + // Arrange the headers + const headers = first.headers ? new Headers(first.headers) : new Headers() + headers.set('x-tsr-redirect', 'manual') - // If the method is GET, we need to move the payload to the query string - if (first.method === 'GET') { - if (type === 'formData') { - throw new Error('FormData is not supported with GET requests') - } - const serializedPayload = await serializePayload(first) - if (serializedPayload !== undefined) { - const encodedPayload = encode({ - payload: await serializePayload(first), - }) - if (url.includes('?')) { - url += `&${encodedPayload}` - } else { - url += `?${encodedPayload}` - } - } - } + if (type === 'payload') { + headers.set('accept', 'application/x-ndjson, application/json') + } - if (url.includes('?')) { - url += `&createServerFn` - } else { - url += `?createServerFn` + // If the method is GET, we need to move the payload to the query string + if (first.method === 'GET') { + if (type === 'formData') { + throw new Error('FormData is not supported with GET requests') } - - let body = undefined - if (first.method === 'POST') { - const fetchBody = await getFetchBody(first) - if (fetchBody?.contentType) { - headers.set('content-type', fetchBody.contentType) + const serializedPayload = await serializePayload(first) + if (serializedPayload !== undefined) { + const encodedPayload = encode({ + payload: serializedPayload, + }) + if (url.includes('?')) { + url += `&${encodedPayload}` + } else { + url += `?${encodedPayload}` } - body = fetchBody?.body } + } - return await getResponse(async () => - handler(url, { - method: first.method, - headers, - signal: first.signal, - body, - }), - ) + let body = undefined + if (first.method === 'POST') { + const fetchBody = await getFetchBody(first) + if (fetchBody?.contentType) { + headers.set('content-type', fetchBody.contentType) + } + body = fetchBody?.body } - // If not a custom fetcher, it was probably - // a `use server` function, so just proxy the arguments - // through as a POST request - return await getResponse(() => + return await getResponse(async () => handler(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(args), + method: first.method, + headers, + signal: first.signal, + body, }), ) } @@ -116,7 +97,7 @@ async function serializePayload( } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (opts.context && Object.keys(opts.context).length > 0) { + if (opts.context && hasOwnProperties(opts.context)) { payloadAvailable = true payloadToSerialize['context'] = opts.context } @@ -139,7 +120,7 @@ async function getFetchBody( if (opts.data instanceof FormData) { let serializedContext = undefined // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (opts.context && Object.keys(opts.context).length > 0) { + if (opts.context && hasOwnProperties(opts.context)) { serializedContext = await serialize(opts.context) } if (serializedContext !== undefined) { @@ -163,17 +144,17 @@ async function getFetchBody( * @throws If the response is invalid or an error occurs during processing. */ async function getResponse(fn: () => Promise) { - const response = await (async () => { - try { - return await fn() - } catch (error) { - if (error instanceof Response) { - return error - } + let response: Response + try { + response = await fn() + } catch (error) { + if (error instanceof Response) { + response = error + } else { console.log(error) throw error } - })() + } if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') { return response diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index 7a45a779b9c..129f80afe78 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -9,9 +9,19 @@ import { import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval' import { getResponse } from './request-response' import { getServerFnById } from './getServerFnById' +import type { Plugin as SerovalPlugin } from 'seroval' let regex: RegExp | undefined = undefined +// Cache serovalPlugins at module level to avoid repeated calls +let serovalPlugins: Array> | undefined = undefined + +// Known FormData 'Content-Type' header values - module-level constant +const FORM_DATA_CONTENT_TYPES = [ + 'multipart/form-data', + 'application/x-www-form-urlencoded', +] + export const handleServerAction = async ({ request, context, @@ -29,6 +39,7 @@ export const handleServerAction = async ({ } const method = request.method + const methodLower = method.toLowerCase() const url = new URL(request.url, 'http://localhost:3000') // extract the serverFnId from the url as host/_serverFn/:serverFnId // Define a regex to match the path and extract the :thing part @@ -36,12 +47,6 @@ export const handleServerAction = async ({ // Execute the regex const match = url.pathname.match(regex) const serverFnId = match ? match[1] : null - const search = Object.fromEntries(url.searchParams.entries()) as { - payload?: any - createServerFn?: boolean - } - - const isCreateServerFn = 'createServerFn' in search if (typeof serverFnId !== 'string') { throw new Error('Invalid server action param for serverFnId: ' + serverFnId) @@ -49,14 +54,12 @@ export const handleServerAction = async ({ const action = await getServerFnById(serverFnId, { fromClient: true }) - // Known FormData 'Content-Type' header values - const formDataContentTypes = [ - 'multipart/form-data', - 'application/x-www-form-urlencoded', - ] + // Initialize serovalPlugins lazily (cached at module level) + if (!serovalPlugins) { + serovalPlugins = getDefaultSerovalPlugins() + } const contentType = request.headers.get('Content-Type') - const serovalPlugins = getDefaultSerovalPlugins() function parsePayload(payload: any) { const parsedPayload = fromJSON(payload, { plugins: serovalPlugins }) @@ -65,16 +68,16 @@ export const handleServerAction = async ({ const response = await (async () => { try { - let result = await (async () => { + const result = await (async () => { // FormData if ( - formDataContentTypes.some( + FORM_DATA_CONTENT_TYPES.some( (type) => contentType && contentType.includes(type), ) ) { // We don't support GET requests with FormData payloads... that seems impossible invariant( - method.toLowerCase() !== 'get', + methodLower !== 'get', 'GET requests with FormData payloads are not supported', ) const formData = await request.formData() @@ -104,21 +107,19 @@ export const handleServerAction = async ({ } // Get requests use the query string - if (method.toLowerCase() === 'get') { - invariant( - isCreateServerFn, - 'expected GET request to originate from createServerFn', - ) - // By default the payload is the search params - let payload: any = search.payload + if (methodLower === 'get') { + // Get payload directly from searchParams + const payloadParam = url.searchParams.get('payload') // If there's a payload, we should try to parse it - payload = payload ? parsePayload(JSON.parse(payload)) : {} + const payload: any = payloadParam + ? parsePayload(JSON.parse(payloadParam)) + : {} payload.context = { ...context, ...payload.context } // Send it through! return await action(payload, signal) } - if (method.toLowerCase() !== 'post') { + if (methodLower !== 'post') { throw new Error('expected POST method') } @@ -127,18 +128,9 @@ export const handleServerAction = async ({ jsonPayload = await request.json() } - // If this POST request was created by createServerFn, - // its payload will be the only argument - if (isCreateServerFn) { - const payload = jsonPayload ? parsePayload(jsonPayload) : {} - payload.context = { ...payload.context, ...context } - return await action(payload, signal) - } - - // Otherwise, we'll spread the payload. Need to - // support `use server` functions that take multiple - // arguments. - return await action(...jsonPayload) + const payload = jsonPayload ? parsePayload(jsonPayload) : {} + payload.context = { ...payload.context, ...context } + return await action(payload, signal) })() // Any time we get a Response back, we should just @@ -148,37 +140,6 @@ export const handleServerAction = async ({ return result.result } - // If this is a non createServerFn request, we need to - // pull out the result from the result object - if (!isCreateServerFn) { - result = result.result - - // The result might again be a response, - // and if it is, return it. - if (result instanceof Response) { - return result - } - } - - // TODO: RSCs Where are we getting this package? - // if (isValidElement(result)) { - // const { renderToPipeableStream } = await import( - // // @ts-expect-error - // 'react-server-dom/server' - // ) - - // const pipeableStream = renderToPipeableStream(result) - - // setHeaders(event, { - // 'Content-Type': 'text/x-component', - // } as any) - - // sendStream(event, response) - // event._handled = true - - // return new Response(null, { status: 200 }) - // } - if (isNotFound(result)) { return isNotFoundResponse(result) }