diff --git a/docs/start/framework/react/guide/server-functions.md b/docs/start/framework/react/guide/server-functions.md index a94c0558180..427a6abcbf6 100644 --- a/docs/start/framework/react/guide/server-functions.md +++ b/docs/start/framework/react/guide/server-functions.md @@ -127,6 +127,123 @@ export const submitForm = createServerFn({ method: 'POST' }) }) ``` +## Raw Handler for Streaming Uploads + +For advanced use cases like streaming file uploads or accessing the raw request body, use `.rawHandler()` instead of `.handler()`. This gives you direct access to the Request object without automatic body parsing. + +### Why Use Raw Handler? + +The raw handler is essential when you need to: + +- **Stream large file uploads** directly to cloud storage without loading them into memory +- **Enforce size limits during upload** rather than after the entire file is loaded +- **Access raw request streams** for custom body parsing (e.g., multipart/form-data with busboy) +- **Implement proper backpressure** for large data transfers +- **Minimize memory footprint** for file uploads + +### Basic Raw Handler + +```tsx +import { createServerFn } from '@tanstack/react-start' + +export const uploadFile = createServerFn({ method: 'POST' }).rawHandler( + async ({ request, signal }) => { + // Access the raw request object + const contentType = request.headers.get('content-type') + const body = await request.text() + + return new Response( + JSON.stringify({ + contentType, + size: body.length, + }), + ) + }, +) +``` + +### Streaming File Upload Example + +With raw handlers, you can stream files directly to cloud storage without buffering them in memory: + +```tsx +import { createServerFn } from '@tanstack/react-start' + +export const uploadFile = createServerFn({ method: 'POST' }) + .middleware([authMiddleware]) // Middleware context is available! + .rawHandler(async ({ request, signal, context }) => { + // Access middleware context (user, auth, etc.) + const userId = context.user.id + + // Access the raw request body stream + const body = request.body + + if (!body) { + return new Response('No file provided', { status: 400 }) + } + + // Stream directly to your storage (S3, Azure, etc.) + // You can use libraries like busboy to parse multipart/form-data + // and enforce size limits DURING upload + await streamToStorage(body, { + userId, + maxSize: 25 * 1024 * 1024, // 25MB limit + signal, // Pass for cancellation support + }) + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' }, + }) + }) +``` + +### Client-Side Usage + +The raw handler works seamlessly from the client with FormData: + +```tsx +import { useMutation } from '@tanstack/react-query' +import { uploadFile } from './server-functions' + +function FileUpload() { + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData() + formData.append('file', file) + + return uploadFile({ data: formData }) + }, + }) + + return ( + { + const file = e.target.files?.[0] + if (file) uploadMutation.mutate(file) + }} + /> + ) +} +``` + +### Key Differences from Regular Handler + +| Feature | `.handler()` | `.rawHandler()` | +| ------------ | ----------------------------------- | ------------------------------ | +| Body Parsing | Automatic (FormData, JSON) | Manual - you control it | +| Memory Usage | Loads entire body into memory first | Can stream directly | +| Size Limits | Can't enforce during upload | Enforce during upload | +| Use Case | Standard data/form handling | Large files, streaming | +| Parameters | `{ data, context, signal }` | `{ request, context, signal }` | + +### Important Notes + +1. **No automatic body parsing** - With `rawHandler`, the request body is NOT automatically parsed. You must handle it yourself. +2. **No data parameter** - Raw handlers receive `{ request, signal, context }` instead of the usual `{ data, context, signal }`. There is no automatic data parsing/validation. +3. **Middleware context is available** - Request middleware still runs and you have full access to the middleware context (authentication, user info, etc.). +4. **Response handling** - Always return a `Response` object from raw handlers. + ## Error Handling & Redirects Server functions can throw errors, redirects, and not-found responses that are handled automatically when called from route lifecycles or components using `useServerFn()`. diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index bc95a6b5b9a..d3b9d84a449 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -88,6 +88,80 @@ export const createServerFn: CreateServerFn = (options, __opts) => { const newOptions = { ...resolvedOptions, inputValidator } return createServerFn(undefined, newOptions) as any }, + rawHandler: (...args) => { + // This function signature changes due to AST transformations + // in the babel plugin. We need to cast it to the correct + // function signature post-transformation + const [extractedFn, serverFn] = args as unknown as [ + CompiledFetcherFn, + RawServerFn, + ] + + // Keep the original function around so we can use it + // in the server environment + const newOptions = { + ...resolvedOptions, + extractedFn, + serverFn: serverFn as any, + rawHandler: true, + } + + const resolvedMiddleware = [ + ...(newOptions.middleware || []), + serverFnBaseToMiddleware(newOptions), + ] + + // We want to make sure the new function has the same + // properties as the original function + + return Object.assign( + async (opts?: CompiledFetcherFnOptions) => { + // Start by executing the client-side middleware chain + return 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 + }) + }, + { + // This copies over the URL, function ID + ...extractedFn, + __rawHandler: true, + // The extracted function on the server-side calls + // this function + __executeServer: async (opts: any, signal: AbortSignal) => { + const startContext = getStartContextServerOnly() + const serverContextAfterGlobalMiddlewares = + startContext.contextAfterGlobalMiddlewares + const ctx = { + ...extractedFn, + ...opts, + context: { + ...serverContextAfterGlobalMiddlewares, + ...opts.context, + }, + signal, + 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, + }), + ) + }, + }, + ) as any + }, handler: (...args) => { // This function signature changes due to AST transformations // in the babel plugin. We need to cast it to the correct @@ -315,6 +389,12 @@ export type ServerFn< ctx: ServerFnCtx, ) => ServerFnReturnType +export type RawServerFn = (ctx: { + request: Request + signal: AbortSignal + context: Expand> +}) => ServerFnReturnType + export interface ServerFnCtx< TRegister, TMethod, @@ -356,6 +436,7 @@ export type ServerFnBaseOptions< TResponse > functionId: string + rawHandler?: boolean } export type ValidateValidatorInput< @@ -513,6 +594,9 @@ export interface ServerFnHandler< TNewResponse >, ) => Fetcher + rawHandler: ( + fn?: RawServerFn, + ) => Fetcher } export interface ServerFnBuilder @@ -611,6 +695,7 @@ export type ServerFnMiddlewareOptions = { sendContext?: any context?: any functionId: string + request?: Request } export type ServerFnMiddlewareResult = ServerFnMiddlewareOptions & { @@ -718,7 +803,19 @@ function serverFnBaseToMiddleware( }, server: async ({ next, ...ctx }) => { // Execute the server function - const result = await options.serverFn?.(ctx as TODO) + let result: any + if (options.rawHandler) { + // For raw handlers, pass request, signal, and context + const ctxWithRequest = ctx as typeof ctx & { request: Request } + result = await (options.serverFn as any)?.({ + request: ctxWithRequest.request, + signal: ctx.signal, + context: ctx.context, + }) + } else { + // For normal handlers, pass the full context + result = await options.serverFn?.(ctx as TODO) + } return next({ ...ctx, diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts index f00f89dbb8a..4b0011114f4 100644 --- a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts +++ b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts @@ -35,7 +35,7 @@ const LookupSetup: Record< LookupKind, { candidateCallIdentifier: Set } > = { - ServerFn: { candidateCallIdentifier: new Set(['handler']) }, + ServerFn: { candidateCallIdentifier: new Set(['handler', 'rawHandler']) }, Middleware: { candidateCallIdentifier: new Set(['server', 'client', 'createMiddlewares']), }, diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts b/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts index 75ebfecdbff..3448868f29d 100644 --- a/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts @@ -16,7 +16,12 @@ export function handleCreateServerFn( // the validator, handler, and middleware methods. Check to make sure they // are children of the createServerFn call expression. - const validMethods = ['middleware', 'inputValidator', 'handler'] as const + const validMethods = [ + 'middleware', + 'inputValidator', + 'handler', + 'rawHandler', + ] as const type ValidMethods = (typeof validMethods)[number] const callExpressionPaths: Record< ValidMethods, @@ -25,6 +30,7 @@ export function handleCreateServerFn( middleware: null, inputValidator: null, handler: null, + rawHandler: null, } const rootCallExpression = getRootCallExpression(path) @@ -84,15 +90,30 @@ export function handleCreateServerFn( // First, we need to move the handler function to a nested function call // that is applied to the arguments passed to the server function. - const handlerFnPath = callExpressionPaths.handler?.get( + // Support both handler and rawHandler + const handlerMethod = callExpressionPaths.handler + ? 'handler' + : callExpressionPaths.rawHandler + ? 'rawHandler' + : null + + if (!handlerMethod) { + throw codeFrameError( + opts.code, + path.node.callee.loc!, + `createServerFn must be called with a "handler" or "rawHandler" property!`, + ) + } + + const handlerFnPath = callExpressionPaths[handlerMethod]?.get( 'arguments.0', ) as babel.NodePath - if (!callExpressionPaths.handler || !handlerFnPath.node) { + if (!handlerFnPath.node) { throw codeFrameError( opts.code, path.node.callee.loc!, - `createServerFn must be called with a "handler" property!`, + `createServerFn must be called with a "handler" or "rawHandler" property!`, ) } @@ -151,6 +172,6 @@ export function handleCreateServerFn( ) if (opts.env === 'server') { - callExpressionPaths.handler.node.arguments.push(handlerFn) + callExpressionPaths[handlerMethod]!.node.arguments.push(handlerFn) } } diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts index 25771328ddc..cfff5d2a2e0 100644 --- a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts +++ b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts @@ -85,8 +85,8 @@ export function createServerFnPlugin( }, code: { // TODO apply this plugin with a different filter per environment so that .createMiddleware() calls are not scanned in server env - // only scan files that mention `.handler(` | `.createMiddleware()` - include: [/\.\s*handler\(/, /\.\s*createMiddleware\(\)/], + // only scan files that mention `.handler(` | `.rawHandler(` | `.createMiddleware()` + include: [/\.\s*handler\(/, /\.\s*rawHandler\(/, /\.\s*createMiddleware\(\)/], }, }, async handler(code, id) { diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 325459b694f..a6e884404a4 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -95,6 +95,106 @@ describe('createServerFn compiles correctly', async () => { `) }) + test('should work with rawHandler', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const myRawFunc = ({ request, signal }) => { + return new Response('hello from raw handler') + } + const myServerFn = createServerFn({ method: 'POST' }).rawHandler(myRawFunc)` + + const compiledResultClient = await compile({ + id: 'test.ts', + code, + env: 'client', + }) + + const compiledResultServer = await compile({ + id: 'test.ts', + code, + env: 'server', + }) + + expect(compiledResultClient!.code).toMatchInlineSnapshot(` + "import { createServerFn } from '@tanstack/react-start'; + const myServerFn = createServerFn({ + method: 'POST' + }).rawHandler((opts, signal) => { + "use server"; + + return myServerFn.__executeServer(opts, signal); + });" + `) + + expect(compiledResultServer!.code).toMatchInlineSnapshot(` + "import { createServerFn } from '@tanstack/react-start'; + const myRawFunc = ({ + request, + signal + }) => { + return new Response('hello from raw handler'); + }; + const myServerFn = createServerFn({ + method: 'POST' + }).rawHandler((opts, signal) => { + "use server"; + + return myServerFn.__executeServer(opts, signal); + }, myRawFunc);" + `) + }) + + test('should work with inline rawHandler', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + export const uploadFile = createServerFn({ method: 'POST' }).rawHandler(async ({ request, signal }) => { + const contentType = request.headers.get('content-type') + return new Response(JSON.stringify({ contentType })) + })` + + const compiledResultClient = await compile({ + id: 'test.ts', + code, + env: 'client', + }) + + const compiledResultServer = await compile({ + id: 'test.ts', + code, + env: 'server', + }) + + expect(compiledResultClient!.code).toMatchInlineSnapshot(` + "import { createServerFn } from '@tanstack/react-start'; + export const uploadFile = createServerFn({ + method: 'POST' + }).rawHandler((opts, signal) => { + "use server"; + + return uploadFile.__executeServer(opts, signal); + });" + `) + + expect(compiledResultServer!.code).toMatchInlineSnapshot(` + "import { createServerFn } from '@tanstack/react-start'; + export const uploadFile = createServerFn({ + method: 'POST' + }).rawHandler((opts, signal) => { + "use server"; + + return uploadFile.__executeServer(opts, signal); + }, async ({ + request, + signal + }) => { + const contentType = request.headers.get('content-type'); + return new Response(JSON.stringify({ + contentType + })); + });" + `) + }) + test('should use dce by default', async () => { const code = ` import { createServerFn } from '@tanstack/react-start' diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index ec9042ec473..3c5d26570a3 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -66,6 +66,11 @@ export const handleServerAction = async ({ const response = await (async () => { try { let result = await (async () => { + // For raw handlers, skip FormData parsing and pass the request directly + if ((action as any).__rawHandler) { + return await action({ context }, signal) + } + // FormData if ( formDataContentTypes.some(