UNPKG

@tanstack/start-server-core

Version:

Modern and scalable routing for React applications

377 lines (337 loc) 11.7 kB
import { createRawStreamRPCPlugin, isNotFound, isRedirect, } from '@tanstack/router-core' import invariant from 'tiny-invariant' import { TSS_FORMDATA_CONTEXT, X_TSS_RAW_RESPONSE, X_TSS_SERIALIZED, getDefaultSerovalPlugins, safeObjectMerge, } from '@tanstack/start-client-core' import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval' import { getResponse } from './request-response' import { getServerFnById } from './getServerFnById' import { TSS_CONTENT_TYPE_FRAMED_VERSIONED, createMultiplexedStream, } from './frame-protocol' import type { Plugin as SerovalPlugin } from 'seroval' // Cache serovalPlugins at module level to avoid repeated calls let serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined // Cache TextEncoder for NDJSON serialization const textEncoder = new TextEncoder() // Known FormData 'Content-Type' header values - module-level constant const FORM_DATA_CONTENT_TYPES = [ 'multipart/form-data', 'application/x-www-form-urlencoded', ] // Maximum payload size for GET requests (1MB) const MAX_PAYLOAD_SIZE = 1_000_000 export const handleServerAction = async ({ request, context, serverFnId, }: { request: Request context: any serverFnId: string }) => { const method = request.method const methodUpper = method.toUpperCase() const url = new URL(request.url) const action = await getServerFnById(serverFnId, { fromClient: true }) // Early method check: reject mismatched HTTP methods before parsing // the request payload (FormData, JSON, query string, etc.) if (action.method && methodUpper !== action.method) { return new Response( `expected ${action.method} method. Got ${methodUpper}`, { status: 405, headers: { Allow: action.method, }, }, ) } const isServerFn = request.headers.get('x-tsr-serverFn') === 'true' // Initialize serovalPlugins lazily (cached at module level) if (!serovalPlugins) { serovalPlugins = getDefaultSerovalPlugins() } const contentType = request.headers.get('Content-Type') function parsePayload(payload: any) { const parsedPayload = fromJSON(payload, { plugins: serovalPlugins }) return parsedPayload as any } const response = await (async () => { try { let res = await (async () => { // FormData if ( FORM_DATA_CONTENT_TYPES.some( (type) => contentType && contentType.includes(type), ) ) { // We don't support GET requests with FormData payloads... that seems impossible invariant( methodUpper !== 'GET', 'GET requests with FormData payloads are not supported', ) const formData = await request.formData() const serializedContext = formData.get(TSS_FORMDATA_CONTEXT) formData.delete(TSS_FORMDATA_CONTEXT) const params = { context, data: formData, method: methodUpper, } if (typeof serializedContext === 'string') { try { const parsedContext = JSON.parse(serializedContext) const deserializedContext = fromJSON(parsedContext, { plugins: serovalPlugins, }) if ( typeof deserializedContext === 'object' && deserializedContext ) { params.context = safeObjectMerge( context, deserializedContext as Record<string, unknown>, ) } } catch (e) { // Log warning for debugging but don't expose to client if (process.env.NODE_ENV === 'development') { console.warn('Failed to parse FormData context:', e) } } } return await action(params) } // Get requests use the query string if (methodUpper === 'GET') { // Get payload directly from searchParams const payloadParam = url.searchParams.get('payload') // Reject oversized payloads to prevent DoS if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) { throw new Error('Payload too large') } // If there's a payload, we should try to parse it const payload: any = payloadParam ? parsePayload(JSON.parse(payloadParam)) : {} payload.context = safeObjectMerge(context, payload.context) payload.method = methodUpper // Send it through! return await action(payload) } let jsonPayload if (contentType?.includes('application/json')) { jsonPayload = await request.json() } const payload = jsonPayload ? parsePayload(jsonPayload) : {} payload.context = safeObjectMerge(payload.context, context) payload.method = methodUpper return await action(payload) })() const unwrapped = res.result || res.error if (isNotFound(res)) { res = isNotFoundResponse(res) } if (!isServerFn) { return unwrapped } if (unwrapped instanceof Response) { if (isRedirect(unwrapped)) { return unwrapped } unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true') return unwrapped } return serializeResult(res) function serializeResult(res: unknown): Response { let nonStreamingBody: any = undefined const alsResponse = getResponse() if (res !== undefined) { // Collect raw streams encountered during serialization const rawStreams = new Map<number, ReadableStream<Uint8Array>>() const rawStreamPlugin = createRawStreamRPCPlugin( (id: number, stream: ReadableStream<Uint8Array>) => { rawStreams.set(id, stream) }, ) // Build plugins with RawStreamRPCPlugin first (before default SSR plugin) const plugins = [rawStreamPlugin, ...(serovalPlugins || [])] // 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, onParse(value) { callbacks.onParse(value) }, onDone() { callbacks.onDone() }, onError: (error) => { callbacks.onError(error) }, }) // If no raw streams and done synchronously, return simple JSON if (done && rawStreams.size === 0) { return new Response( nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined, { status: alsResponse.status, statusText: alsResponse.statusText, headers: { 'Content-Type': 'application/json', [X_TSS_SERIALIZED]: 'true', }, }, ) } // If we have raw streams, use framed protocol if (rawStreams.size > 0) { // Create a stream of JSON chunks (NDJSON style) const jsonStream = new ReadableStream<string>({ start(controller) { callbacks.onParse = (value) => { controller.enqueue(JSON.stringify(value) + '\n') } callbacks.onDone = () => { try { controller.close() } catch { // Already closed } } callbacks.onError = (error) => controller.error(error) // Emit initial body if we have one if (nonStreamingBody !== undefined) { callbacks.onParse(nonStreamingBody) } }, }) // Create multiplexed stream with JSON and raw streams const multiplexedStream = createMultiplexedStream( jsonStream, rawStreams, ) return new Response(multiplexedStream, { status: alsResponse.status, statusText: alsResponse.statusText, headers: { 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, [X_TSS_SERIALIZED]: 'true', }, }) } // No raw streams but not done yet - use standard NDJSON streaming const stream = new ReadableStream({ start(controller) { callbacks.onParse = (value) => controller.enqueue( textEncoder.encode(JSON.stringify(value) + '\n'), ) callbacks.onDone = () => { try { controller.close() } catch (error) { controller.error(error) } } callbacks.onError = (error) => controller.error(error) // stream 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', }, }) } return new Response(undefined, { status: alsResponse.status, statusText: alsResponse.statusText, }) } } catch (error: any) { if (error instanceof Response) { return error } // else if ( // isPlainObject(error) && // 'result' in error && // error.result instanceof Response // ) { // return error.result // } // Currently this server-side context has no idea how to // build final URLs, so we need to defer that to the client. // The client will check for __redirect and __notFound keys, // and if they exist, it will handle them appropriately. if (isNotFound(error)) { return isNotFoundResponse(error) } console.info() console.info('Server Fn Error!') console.info() console.error(error) console.info() const serializedError = JSON.stringify( await Promise.resolve( toCrossJSONAsync(error, { refs: new Map(), plugins: serovalPlugins, }), ), ) const response = getResponse() return new Response(serializedError, { status: response.status ?? 500, statusText: response.statusText, headers: { 'Content-Type': 'application/json', [X_TSS_SERIALIZED]: 'true', }, }) } })() return response } function isNotFoundResponse(error: any) { const { headers, ...rest } = error return new Response(JSON.stringify(rest), { status: 404, headers: { 'Content-Type': 'application/json', ...(headers || {}), }, }) }