From 3e56a903bf0caa2dd70b7753ff43e860a5d343b3 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 11 Nov 2025 20:27:24 +0200 Subject: [PATCH 1/3] Vercel Fluid initial implementation. --- packages/api/src/vercel.ts | 124 ++++++++++++++++++++++++++ packages/internal/src/build/api.ts | 11 +++ packages/internal/src/build/vercel.ts | 60 +++++++++++++ packages/project-config/src/config.ts | 5 ++ 4 files changed, 200 insertions(+) create mode 100644 packages/api/src/vercel.ts create mode 100644 packages/internal/src/build/vercel.ts diff --git a/packages/api/src/vercel.ts b/packages/api/src/vercel.ts new file mode 100644 index 000000000000..44442f8b24d5 --- /dev/null +++ b/packages/api/src/vercel.ts @@ -0,0 +1,124 @@ +import type { + APIGatewayProxyEvent, + APIGatewayProxyResult, + Context as LambdaContext, +} from 'aws-lambda' + +// This function is duplicated from @redwoodjs/api-server. We should find a way +// to share it, but for now this is the easiest solution. +export const mergeMultiValueHeaders = ( + headers: { [name: string]: string } | undefined, + multiValueHeaders: { [name: string]: string[] } | undefined, +) => { + const mergedHeaders: { [name: string]: string[] } = {} + + // Convert headers to multi-value headers + if (headers) { + for (const [name, value] of Object.entries(headers)) { + mergedHeaders[name.toLowerCase()] = [value] + } + } + + // Merge multi-value headers + if (multiValueHeaders) { + for (const [name, values] of Object.entries(multiValueHeaders)) { + mergedHeaders[name.toLowerCase()] = [ + ...(mergedHeaders[name.toLowerCase()] || []), + ...values, + ] + } + } + + return mergedHeaders +} + +export function lambdaResponseToWebResponse( + lambdaResponse: APIGatewayProxyResult, +): Response { + const { + statusCode = 200, + headers, + body = '', + multiValueHeaders, + isBase64Encoded, + } = lambdaResponse + + const mergedHeaders = mergeMultiValueHeaders(headers, multiValueHeaders) + + // Vercel edge functions don't support buffer + // But they do support string, blob, etc. + const responseBody = isBase64Encoded + ? atob(body) // atob is available in edge runtimes + : body + + return new Response(responseBody, { + status: statusCode, + headers: mergedHeaders, + }) +} + +export async function webRequestToLambdaEvent( + request: Request, + context?: LambdaContext, // Optional context from Vercel +): Promise { + const url = new URL(request.url) + const headers: Record = {} + request.headers.forEach((value, key) => { + headers[key] = value + }) + + const queryStringParameters: Record = {} + const multiValueQueryStringParameters: Record = {} + + for (const [key, value] of url.searchParams.entries()) { + // The first value for a key becomes the single value + if (queryStringParameters[key] === undefined) { + queryStringParameters[key] = value + } + + if (!multiValueQueryStringParameters[key]) { + multiValueQueryStringParameters[key] = [] + } + multiValueQueryStringParameters[key].push(value) + } + + // Vercel seems to add this header + const sourceIp = headers['x-real-ip'] || '127.0.0.1' + + const body = await request.text() + + return { + httpMethod: request.method, + headers: headers, + path: url.pathname, + queryStringParameters, + multiValueQueryStringParameters, + requestContext: { + requestId: headers['x-vercel-id'] || context?.awsRequestId || '', + identity: { + sourceIp: sourceIp, + // Other identity properties can be added here if available from Vercel + }, + // The rest of the request context can be filled with mock or available data + accountId: 'mock-account-id', + apiId: 'mock-api-id', + authorizer: {}, + domainName: url.hostname, + domainPrefix: url.hostname.split('.')[0], + extendedRequestId: headers['x-vercel-id'] || '', + httpMethod: request.method, + path: url.pathname, + protocol: 'HTTP/1.1', + stage: 'prod', + requestTime: new Date().toISOString(), + requestTimeEpoch: Date.now(), + resourceId: 'mock-resource-id', + resourcePath: url.pathname, + }, + body: body, + isBase64Encoded: false, // Assuming body is not base64 encoded from web request + multiValueHeaders: {}, // Can be built from request.headers if needed + stageVariables: null, + resource: '', + } +} diff --git a/packages/internal/src/build/api.ts b/packages/internal/src/build/api.ts index 74a0d9172e2f..53a1079c258a 100644 --- a/packages/internal/src/build/api.ts +++ b/packages/internal/src/build/api.ts @@ -7,6 +7,7 @@ import { transformWithBabel, } from '@redwoodjs/babel-config' import { + isVercelFluidDeploy, getConfig, getPaths, projectSideIsEsm, @@ -14,6 +15,8 @@ import { import { findApiFiles } from '../files' +import { wrapVercelHandler } from './vercel' + let BUILD_CTX: BuildContext | null = null export const buildApi = async () => { @@ -46,6 +49,13 @@ const runRwBabelTransformsPlugin = { const rwjsConfig = getConfig() build.onLoad({ filter: /\.(js|ts|tsx|jsx)$/ }, async (args) => { + const isVercel = isVercelFluidDeploy() + let code = fs.readFileSync(args.path, 'utf-8') + + if (isVercel) { + code = wrapVercelHandler(code) + } + // @TODO Implement LRU cache? Unsure how much of a performance benefit its going to be // Generate a CRC of file contents, then save it to LRU cache with a limit // without LRU cache, the memory usage can become unbound @@ -57,6 +67,7 @@ const runRwBabelTransformsPlugin = { rwjsConfig.experimental.opentelemetry.wrapApi, projectIsEsm: projectSideIsEsm('api'), }), + code, ) if (transformedCode?.code) { diff --git a/packages/internal/src/build/vercel.ts b/packages/internal/src/build/vercel.ts new file mode 100644 index 000000000000..08ed3e20af6b --- /dev/null +++ b/packages/internal/src/build/vercel.ts @@ -0,0 +1,60 @@ +import type { types } from '@babel/core' +import { parse, traverse, template } from '@babel/core' +import generate from '@babel/generator' + +export const wrapVercelHandler = (code: string) => { + const ast = parse(code, { + sourceType: 'module', + plugins: [ + ['@babel/plugin-transform-typescript', { isTSX: true }], + ['@babel/plugin-syntax-jsx', {}], + ], + }) + + if (!ast) { + return code + } + + let hasHandler = false + traverse(ast, { + ExportNamedDeclaration(path) { + if ( + path.node.declaration?.type === 'VariableDeclaration' && + path.node.declaration.declarations[0].id.type === 'Identifier' && + path.node.declaration.declarations[0].id.name === 'handler' + ) { + hasHandler = true + // Rename the original handler + path.node.declaration.declarations[0].id.name = '_redwoodHandler' + + // Create the new wrapped handler + const wrappedHandler = template.ast(` + import { webRequestToLambdaEvent, lambdaResponseToWebResponse } from '@redwoodjs/api/vercel' + export const handler = async (req, context) => { + const event = await webRequestToLambdaEvent(req, context) + const response = await _redwoodHandler(event, context) + return lambdaResponseToWebResponse(response) + } + `) + + // Add the new handler after the renamed one + path.insertAfter(wrappedHandler) + + // Add the import statement at the top of the file + const importStatement = template.ast(` + import { webRequestToLambdaEvent, lambdaResponseToWebResponse } from '@redwoodjs/api/vercel' + `) + const program = path.findParent((p) => p.isProgram()) + if (program) { + ;(program.node as types.Program).body.unshift(importStatement) + } + } + }, + }) + + if (!hasHandler) { + return code + } + + return generate(ast).code +} diff --git a/packages/project-config/src/config.ts b/packages/project-config/src/config.ts index d1ff478f0985..991aec7f5a69 100644 --- a/packages/project-config/src/config.ts +++ b/packages/project-config/src/config.ts @@ -233,3 +233,8 @@ export function getRawConfig(configPath = getConfigPath()) { throw new Error(`Could not parse "${configPath}": ${e}`) } } + +export const isVercelFluidDeploy = () => { + const config = getConfig() + return config.deploy?.target === 'vercel' && config.deploy?.vercel?.fluid +} From e58b4c19449a57dff937afbb167427a5d488e8e8 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 11 Nov 2025 21:29:25 +0200 Subject: [PATCH 2/3] Add worklogs. --- .../2025-11-11-vercel-fluid-transition.md | 18 ++++++++ ...025-11-11-vercel-fluid-request-response.md | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 .worklogs/peterp/2025-11-11-vercel-fluid-transition.md create mode 100644 docs/architecture/2025-11-11-vercel-fluid-request-response.md diff --git a/.worklogs/peterp/2025-11-11-vercel-fluid-transition.md b/.worklogs/peterp/2025-11-11-vercel-fluid-transition.md new file mode 100644 index 000000000000..724fdc2be499 --- /dev/null +++ b/.worklogs/peterp/2025-11-11-vercel-fluid-transition.md @@ -0,0 +1,18 @@ +# Vercel Fluid request/response migration + +## Attempt 1: Lambda signature adapter + +- Wrapped lambda handlers with `webRequestToLambdaEvent` and `lambdaResponseToWebResponse` bridge. +- Hooked the wrapper into the esbuild pipeline when `deploy.target` was `vercel`. +- Confirmed approach still relied on translating back to AWS Lambda semantics, which blocks access to Fluid-specific features like streaming responses. + +## Decision point + +- Fluid compute requires user handlers to expose the Request/Response surface natively, per Vercel documentation ([Fluid Compute docs](https://vercel.com/docs/fluid-compute)). +- Builder-level translation preserves legacy API but prevents adoption of streaming APIs defined in the [Build Output API primitives for Node.js runtimes](https://vercel.com/docs/build-output-api/primitives#node.js-config). + +## Current direction + +- Ship an opt-in package variant that expects handlers to export web-standard `Request`/`Response` entrypoints (e.g. `export const GET`). +- Deprecate the AWS Lambda signature for projects that install the Fluid package. +- Update generators, type definitions, and docs to steer users toward the new interface. diff --git a/docs/architecture/2025-11-11-vercel-fluid-request-response.md b/docs/architecture/2025-11-11-vercel-fluid-request-response.md new file mode 100644 index 000000000000..71f371d347ba --- /dev/null +++ b/docs/architecture/2025-11-11-vercel-fluid-request-response.md @@ -0,0 +1,41 @@ +# Vercel Fluid Request/Response Handlers + +## Context + +- Vercel Fluid compute requires web-standard `Request`/`Response` handlers to unlock streaming, `waitUntil`, and shared concurrency features ([Fluid Compute docs](https://vercel.com/docs/fluid-compute)). +- The Build Output API for Node.js documents the framework-level entrypoints (`export const GET`, `POST`, etc.) that Vercel expects for routing ([Build Output API primitives](https://vercel.com/docs/build-output-api/primitives#node.js-config)). +- Redwood API functions currently expose the AWS Lambda `(event, context)` signature, which is incompatible with Fluid-only capabilities. + +## Goals + +- Provide an opt-in package variant that requires Redwood API functions to export web-standard handlers and drops the Lambda adapter. +- Ensure generated scaffold code, TypeScript types, and CLI validation enforce `Request`/`Response` usage when the Fluid package is installed. +- Maintain backwards compatibility for existing deployments that stay on the Lambda-compatible package. + +## Non-goals + +- Do not migrate non-Vercel providers to the new surface in this iteration. +- Do not remove legacy Lambda support from the default package. + +## High-level design + +1. Publish a new entry point (e.g. `@redwoodjs/api/fluid`) that exports a base handler signature `(request: Request, context?: FluidContext) => Response | Promise` and helper utilities. +2. Update the API build pipeline to fail fast if a function exports `handler` with Lambda parameters while Fluid mode is enabled; require named exports like `GET`, `POST`, or a default `handle` compatible with Vercel's routing. +3. Adjust CLI generators (`yarn rw g function`) to emit Request/Response templates when the Fluid package is detected. +4. Extend dev server and test harness to call functions using the new signature so local development matches production. +5. Provide codemods or lint rules to help migrate existing functions to the new API when opting in. + +## Open questions + +- How should multi-method functions (`GET`, `POST`, etc.) map onto Redwood's router and auth hooks? +- What fallback context (if any) needs to be supplied to mimic `event.requestContext` data previously available in Lambda? +- Do background tasks like `waitUntil` require additional runtime plumbing inside the dev server? + +## Tasks + +1. Detect Fluid mode via package flag or `redwood.toml` config and gate behavior across CLI, builder, and dev server. +2. Implement runtime adapters in `@redwoodjs/api/fluid` that expose helper APIs (auth decoding, logger, db access) over `Request`/`Response`. +3. Update API function generator templates and associated tests to emit new signatures. +4. Enforce signature validation in the build step with clear diagnostics when legacy `handler(event, context)` exports are found. +5. Modify dev server (`@redwoodjs/api-server`) to invoke functions through the web-standard signature. +6. Refresh documentation and migration guides covering Fluid opt-in, generator changes, and manual migration steps. From ea1da1c6a131e93620aa9f727712f2ad7c0a23c3 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 11 Nov 2025 21:42:58 +0200 Subject: [PATCH 3/3] Vercel Fluid handlers. --- .../2025-11-11-vercel-fluid-transition.md | 60 +++++ docs/docs/vercel-fluid-migration.md | 209 ++++++++++++++++++ .../api-server/src/plugins/lambdaLoader.ts | 166 ++++++++++---- .../src/requestHandlers/fluidFastify.ts | 53 +++++ packages/api/package.json | 4 + packages/api/src/fluid.ts | 84 +++++++ packages/internal/src/build/api.ts | 29 ++- packages/internal/src/build/vercel.ts | 77 ++++++- packages/project-config/src/config.ts | 10 + 9 files changed, 636 insertions(+), 56 deletions(-) create mode 100644 docs/docs/vercel-fluid-migration.md create mode 100644 packages/api-server/src/requestHandlers/fluidFastify.ts create mode 100644 packages/api/src/fluid.ts diff --git a/.worklogs/peterp/2025-11-11-vercel-fluid-transition.md b/.worklogs/peterp/2025-11-11-vercel-fluid-transition.md index 724fdc2be499..977db4ac7913 100644 --- a/.worklogs/peterp/2025-11-11-vercel-fluid-transition.md +++ b/.worklogs/peterp/2025-11-11-vercel-fluid-transition.md @@ -16,3 +16,63 @@ - Ship an opt-in package variant that expects handlers to export web-standard `Request`/`Response` entrypoints (e.g. `export const GET`). - Deprecate the AWS Lambda signature for projects that install the Fluid package. - Update generators, type definitions, and docs to steer users toward the new interface. + +## Implementation (Completed) + +### Configuration + +- Added TypeScript types for Vercel Fluid configuration in `packages/project-config/src/config.ts` +- Added `DeployConfig` and `VercelDeployConfig` interfaces +- `isVercelFluidDeploy()` function gates behavior based on `deploy.vercel.fluid` flag in `redwood.toml` + +### @redwoodjs/api/fluid Package + +Created new entry point at `packages/api/src/fluid.ts`: + +- `FluidHandler` type: `(request: Request, context?: FluidContext) => Response | Promise` +- `FluidContext` interface with `waitUntil` and `requestId` properties +- Helper utilities: `json()`, `parseBody()`, `getQueryParams()`, `createFluidResponse()` +- Support for named HTTP method exports: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `HEAD` +- Added export to `package.json` + +### Build Pipeline + +Modified `packages/internal/src/build/vercel.ts`: + +- Added `validateFluidHandler()` function that detects legacy Lambda handlers +- Fails build with clear error message pointing to migration guide +- Detects HTTP method exports (`GET`, `POST`, etc.) and default exports + +Updated `packages/internal/src/build/api.ts`: + +- Calls `validateFluidHandler()` when Fluid mode is enabled +- Uses `transformAsync` directly to handle pre-validated code + +### Dev Server + +Created `packages/api-server/src/requestHandlers/fluidFastify.ts`: + +- Converts Fastify requests to web-standard `Request` objects +- Invokes Fluid handlers with `Request`/`Response` signature +- Provides mock `FluidContext` for local development + +Modified `packages/api-server/src/plugins/lambdaLoader.ts`: + +- Added `FLUID_FUNCTIONS` registry for Fluid handlers +- Added `isFluidMode()` detection function +- Modified `setLambdaFunctions()` to load HTTP method exports when in Fluid mode +- Updated `lambdaRequestHandler()` to route to appropriate handler based on mode +- Supports method-specific routing with 405 responses for unsupported methods + +### Documentation + +Created `docs/docs/vercel-fluid-migration.md`: + +- Configuration instructions +- Before/after code examples +- HTTP method handler patterns +- Helper utility usage +- Streaming response examples +- Background task examples with `waitUntil` +- Migration checklist +- Common patterns and limitations diff --git a/docs/docs/vercel-fluid-migration.md b/docs/docs/vercel-fluid-migration.md new file mode 100644 index 000000000000..e310b6c9679c --- /dev/null +++ b/docs/docs/vercel-fluid-migration.md @@ -0,0 +1,209 @@ +# Migrating to Vercel Fluid Request/Response Handlers + +## Overview + +Vercel Fluid compute requires API functions to use web-standard `Request`/`Response` handlers instead of AWS Lambda `(event, context)` signatures. This unlocks streaming responses, `waitUntil`, and shared concurrency features. + +## Configuration + +Enable Fluid mode in `redwood.toml`: + +```toml +[deploy] +target = "vercel" + +[deploy.vercel] +fluid = true +``` + +## Handler Migration + +### Before (Lambda signature) + +```typescript +import type { APIGatewayEvent, Context } from 'aws-lambda' + +export const handler = async (event: APIGatewayEvent, context: Context) => { + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: 'example' }), + } +} +``` + +### After (Request/Response signature) + +```typescript +import { json } from '@redwoodjs/api/fluid' + +export const GET = async (request: Request) => { + return json({ data: 'example' }) +} +``` + +## HTTP Method Handlers + +Fluid functions support named HTTP method exports: + +```typescript +import { json, parseBody } from '@redwoodjs/api/fluid' + +export const GET = async (request: Request) => { + return json({ message: 'GET request' }) +} + +export const POST = async (request: Request) => { + const body = await parseBody(request) + return json({ received: body }) +} + +export const PUT = async (request: Request) => { + return json({ message: 'PUT request' }) +} +``` + +Supported methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `HEAD` + +## Default Handler + +Functions can export a default handler that handles all methods: + +```typescript +export default async (request: Request) => { + return new Response(JSON.stringify({ method: request.method }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} +``` + +## Helper Utilities + +### JSON Response + +```typescript +import { json } from '@redwoodjs/api/fluid' + +export const GET = async (request: Request) => { + return json({ data: 'value' }, { status: 200 }) +} +``` + +### Parse Request Body + +```typescript +import { parseBody } from '@redwoodjs/api/fluid' + +export const POST = async (request: Request) => { + const body = await parseBody(request) + return json({ received: body }) +} +``` + +### Query Parameters + +```typescript +import { getQueryParams, json } from '@redwoodjs/api/fluid' + +export const GET = async (request: Request) => { + const params = getQueryParams(request) + return json({ params }) +} +``` + +## Streaming Responses + +```typescript +export const GET = async (request: Request) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('chunk 1\n') + controller.enqueue('chunk 2\n') + controller.close() + }, + }) + + return new Response(stream, { + headers: { 'Content-Type': 'text/plain' }, + }) +} +``` + +## Background Tasks with waitUntil + +```typescript +import type { FluidContext } from '@redwoodjs/api/fluid' +import { json } from '@redwoodjs/api/fluid' + +export const POST = async (request: Request, context?: FluidContext) => { + context?.waitUntil?.( + fetch('https://api.example.com/log', { + method: 'POST', + body: JSON.stringify({ event: 'processed' }), + }) + ) + + return json({ status: 'accepted' }) +} +``` + +## Migration Checklist + +- [ ] Enable Fluid mode in `redwood.toml` +- [ ] Replace Lambda `handler` exports with HTTP method exports (`GET`, `POST`, etc.) +- [ ] Convert `APIGatewayEvent` parameter access to `Request` API +- [ ] Convert `APIGatewayProxyResult` returns to `Response` objects +- [ ] Replace `event.queryStringParameters` with `getQueryParams(request)` +- [ ] Replace `JSON.parse(event.body)` with `parseBody(request)` +- [ ] Test functions locally with `yarn rw dev` +- [ ] Deploy to Vercel + +## Common Patterns + +### Accessing Headers + +```typescript +export const GET = async (request: Request) => { + const authHeader = request.headers.get('authorization') + return json({ authenticated: !!authHeader }) +} +``` + +### Setting Cookies + +```typescript +export const POST = async (request: Request) => { + return new Response(JSON.stringify({ success: true }), { + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': 'session=abc123; Path=/; HttpOnly', + }, + }) +} +``` + +### Error Handling + +```typescript +export const GET = async (request: Request) => { + try { + const data = await fetchData() + return json(data) + } catch (error) { + return json({ error: error.message }, { status: 500 }) + } +} +``` + +## Limitations + +- Fluid mode only applies when deploying to Vercel +- Legacy Lambda signature is not supported in Fluid mode +- Build fails with validation error if `handler` export is detected + +## Support + +For questions or issues, see the [Vercel Fluid documentation](https://vercel.com/docs/fluid-compute). diff --git a/packages/api-server/src/plugins/lambdaLoader.ts b/packages/api-server/src/plugins/lambdaLoader.ts index 40bcd8be79b9..c6d1297b0506 100644 --- a/packages/api-server/src/plugins/lambdaLoader.ts +++ b/packages/api-server/src/plugins/lambdaLoader.ts @@ -11,50 +11,93 @@ import type { } from 'fastify' import { escape } from 'lodash' -import { getPaths } from '@redwoodjs/project-config' +import type { FluidHandler } from '@redwoodjs/api/fluid' +import { getPaths, getConfig } from '@redwoodjs/project-config' import { requestHandler } from '../requestHandlers/awsLambdaFastify' +import { fluidRequestHandler } from '../requestHandlers/fluidFastify' export type Lambdas = Record +export type FluidHandlers = Record> export const LAMBDA_FUNCTIONS: Lambdas = {} +export const FLUID_FUNCTIONS: FluidHandlers = {} -// Import the API functions and add them to the LAMBDA_FUNCTIONS object +// Import the API functions and add them to the LAMBDA_FUNCTIONS or FLUID_FUNCTIONS object + +const isFluidMode = () => { + const config = getConfig() + return config.deploy?.target === 'vercel' && config.deploy?.vercel?.fluid +} export const setLambdaFunctions = async (foundFunctions: string[]) => { const tsImport = Date.now() console.log(chalk.dim.italic('Importing Server Functions... ')) + const fluidMode = isFluidMode() const imports = foundFunctions.map(async (fnPath) => { const ts = Date.now() const routeName = path.basename(fnPath).replace('.js', '') const fnImport = await import(`file://${fnPath}`) - const handler: Handler = (() => { - if ('handler' in fnImport) { - // ESModule export of handler - when using `export const handler = ...` - most common case - return fnImport.handler - } - if ('default' in fnImport) { - if ('handler' in fnImport.default) { - // CommonJS export of handler - when using `module.exports.handler = ...` or `export default { handler: ... }` - // This is less common, but required for bundling tools that export a default object, like esbuild or rollup - return fnImport.default.handler + + if (fluidMode) { + const httpMethods = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', + 'HEAD', + ] + const handlers: Record = {} + let hasHandlers = false + + for (const method of httpMethods) { + if (method in fnImport) { + handlers[method] = fnImport[method] + hasHandlers = true } - // Default export is not expected, so skip it } - // If no handler is found, return undefined - we do not want to throw an error - })() - LAMBDA_FUNCTIONS[routeName] = handler - if (!handler) { - console.warn( - routeName, - 'at', - fnPath, - 'does not have a function called handler defined.', - ) + if ('default' in fnImport && typeof fnImport.default === 'function') { + handlers.default = fnImport.default + hasHandlers = true + } + + if (hasHandlers) { + FLUID_FUNCTIONS[routeName] = handlers + } else { + console.warn( + routeName, + 'at', + fnPath, + 'does not have HTTP method handlers (GET, POST, etc.) or a default handler.', + ) + } + } else { + const handler: Handler = (() => { + if ('handler' in fnImport) { + return fnImport.handler + } + if ('default' in fnImport) { + if ('handler' in fnImport.default) { + return fnImport.default.handler + } + } + })() + + LAMBDA_FUNCTIONS[routeName] = handler + if (!handler) { + console.warn( + routeName, + 'at', + fnPath, + 'does not have a function called handler defined.', + ) + } } - // TODO: Use terminal link. + console.log( chalk.magenta('/' + routeName), chalk.dim.italic(Date.now() - ts + ' ms'), @@ -121,31 +164,76 @@ interface LambdaHandlerRequest extends RequestGenericInterface { /** This will take a fastify request - Then convert it to a lambdaEvent, and pass it to the the appropriate handler for the routeName - The LAMBDA_FUNCTIONS lookup has been populated already by this point + Then convert it to a lambdaEvent or Request object, and pass it to the appropriate handler for the routeName + The LAMBDA_FUNCTIONS or FLUID_FUNCTIONS lookup has been populated already by this point **/ export const lambdaRequestHandler = async ( req: FastifyRequest, reply: FastifyReply, ) => { const { routeName } = req.params + const fluidMode = isFluidMode() + + if (fluidMode) { + const handlers = FLUID_FUNCTIONS[routeName] + if (!handlers) { + const errorMessage = `Function "${routeName}" was not found.` + req.log.error(errorMessage) + reply.status(404) + + if (process.env.NODE_ENV === 'development') { + const devError = { + error: errorMessage, + availableFunctions: Object.keys(FLUID_FUNCTIONS), + } + reply.send(devError) + } else { + reply.send(escape(errorMessage)) + } + + return + } - if (!LAMBDA_FUNCTIONS[routeName]) { - const errorMessage = `Function "${routeName}" was not found.` - req.log.error(errorMessage) - reply.status(404) + const method = req.method.toUpperCase() + const handler = handlers[method] || handlers.default - if (process.env.NODE_ENV === 'development') { - const devError = { - error: errorMessage, - availableFunctions: Object.keys(LAMBDA_FUNCTIONS), + if (!handler) { + const errorMessage = `Method "${method}" not supported for function "${routeName}".` + req.log.error(errorMessage) + reply.status(405) + + if (process.env.NODE_ENV === 'development') { + const devError = { + error: errorMessage, + availableMethods: Object.keys(handlers), + } + reply.send(devError) + } else { + reply.send(escape(errorMessage)) } - reply.send(devError) - } else { - reply.send(escape(errorMessage)) + + return } - return + return fluidRequestHandler(req, reply, handler) + } else { + if (!LAMBDA_FUNCTIONS[routeName]) { + const errorMessage = `Function "${routeName}" was not found.` + req.log.error(errorMessage) + reply.status(404) + + if (process.env.NODE_ENV === 'development') { + const devError = { + error: errorMessage, + availableFunctions: Object.keys(LAMBDA_FUNCTIONS), + } + reply.send(devError) + } else { + reply.send(escape(errorMessage)) + } + + return + } + return requestHandler(req, reply, LAMBDA_FUNCTIONS[routeName]) } - return requestHandler(req, reply, LAMBDA_FUNCTIONS[routeName]) } diff --git a/packages/api-server/src/requestHandlers/fluidFastify.ts b/packages/api-server/src/requestHandlers/fluidFastify.ts new file mode 100644 index 000000000000..800025fdb8ae --- /dev/null +++ b/packages/api-server/src/requestHandlers/fluidFastify.ts @@ -0,0 +1,53 @@ +import type { FastifyRequest, FastifyReply } from 'fastify' + +import type { FluidHandler } from '@redwoodjs/api/fluid' + +export const fluidRequestHandler = async ( + req: FastifyRequest, + reply: FastifyReply, + handler: FluidHandler, +) => { + const protocol = req.protocol + const host = req.headers.host || 'localhost' + const url = `${protocol}://${host}${req.url}` + + const headers = new Headers() + for (const [key, value] of Object.entries(req.headers)) { + if (value !== undefined) { + if (Array.isArray(value)) { + for (const v of value) { + headers.append(key, v) + } + } else { + headers.set(key, value) + } + } + } + + const request = new Request(url, { + method: req.method, + headers, + body: + req.method !== 'GET' && req.method !== 'HEAD' ? req.rawBody : undefined, + }) + + const fluidContext = { + requestId: req.id, + } + + try { + const response = await handler(request, fluidContext) + + reply.status(response.status) + + response.headers.forEach((value: string, key: string) => { + reply.header(key, value) + }) + + const body = await response.text() + return reply.send(body) + } catch (error: any) { + req.log.error(error) + reply.status(500).send() + } +} diff --git a/packages/api/package.json b/packages/api/package.json index 05a999a7d210..e5d7a7a30b44 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -25,6 +25,10 @@ "./webhooks": { "types": "./dist/webhooks/index.d.ts", "default": "./dist/webhooks/index.js" + }, + "./fluid": { + "types": "./dist/fluid.d.ts", + "default": "./dist/fluid.js" } }, "main": "./dist/index.js", diff --git a/packages/api/src/fluid.ts b/packages/api/src/fluid.ts new file mode 100644 index 000000000000..ab08156099d5 --- /dev/null +++ b/packages/api/src/fluid.ts @@ -0,0 +1,84 @@ +export interface FluidContext { + waitUntil?: (promise: Promise) => void + requestId?: string +} + +export type FluidHandler = ( + request: Request, + context?: FluidContext, +) => Response | Promise + +export type FluidHttpMethodHandler = FluidHandler + +export interface FluidHandlerExports { + GET?: FluidHttpMethodHandler + POST?: FluidHttpMethodHandler + PUT?: FluidHttpMethodHandler + PATCH?: FluidHttpMethodHandler + DELETE?: FluidHttpMethodHandler + OPTIONS?: FluidHttpMethodHandler + HEAD?: FluidHttpMethodHandler + default?: FluidHandler +} + +export function createFluidResponse( + body: BodyInit | null, + init?: ResponseInit, +): Response { + return new Response(body, init) +} + +export function json( + data: unknown, + init?: Omit & { + headers?: HeadersInit + }, +): Response { + const headers = new Headers(init?.headers) + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json') + } + + return new Response(JSON.stringify(data), { + ...init, + headers, + }) +} + +export async function parseBody(request: Request): Promise { + const contentType = request.headers.get('content-type') + + if (!contentType) { + return null + } + + if (contentType.includes('application/json')) { + return await request.json() + } + + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await request.formData() + const body: Record = {} + for (const [key, value] of formData.entries()) { + body[key] = value + } + return body + } + + if (contentType.includes('multipart/form-data')) { + return await request.formData() + } + + return await request.text() +} + +export function getQueryParams(request: Request): Record { + const url = new URL(request.url) + const params: Record = {} + + for (const [key, value] of url.searchParams.entries()) { + params[key] = value + } + + return params +} diff --git a/packages/internal/src/build/api.ts b/packages/internal/src/build/api.ts index 53a1079c258a..8f2932b2d170 100644 --- a/packages/internal/src/build/api.ts +++ b/packages/internal/src/build/api.ts @@ -1,10 +1,11 @@ +import { transformAsync } from '@babel/core' import type { BuildContext, BuildOptions, PluginBuild } from 'esbuild' import { build, context } from 'esbuild' import fs from 'fs-extra' import { getApiSideBabelPlugins, - transformWithBabel, + getApiSideDefaultBabelConfig, } from '@redwoodjs/babel-config' import { isVercelFluidDeploy, @@ -15,7 +16,7 @@ import { import { findApiFiles } from '../files' -import { wrapVercelHandler } from './vercel' +import { validateFluidHandler } from './vercel' let BUILD_CTX: BuildContext | null = null @@ -49,26 +50,32 @@ const runRwBabelTransformsPlugin = { const rwjsConfig = getConfig() build.onLoad({ filter: /\.(js|ts|tsx|jsx)$/ }, async (args) => { - const isVercel = isVercelFluidDeploy() - let code = fs.readFileSync(args.path, 'utf-8') + const isFluid = isVercelFluidDeploy() + const code = fs.readFileSync(args.path, 'utf-8') - if (isVercel) { - code = wrapVercelHandler(code) + if (isFluid) { + validateFluidHandler(code, args.path) } // @TODO Implement LRU cache? Unsure how much of a performance benefit its going to be // Generate a CRC of file contents, then save it to LRU cache with a limit // without LRU cache, the memory usage can become unbound - const transformedCode = await transformWithBabel( - args.path, - getApiSideBabelPlugins({ + const defaultOptions = getApiSideDefaultBabelConfig({ + projectIsEsm: projectSideIsEsm('api'), + }) + + const transformedCode = await transformAsync(code, { + ...defaultOptions, + cwd: getPaths().api.base, + filename: args.path, + sourceMaps: 'inline', + plugins: getApiSideBabelPlugins({ openTelemetry: rwjsConfig.experimental.opentelemetry.enabled && rwjsConfig.experimental.opentelemetry.wrapApi, projectIsEsm: projectSideIsEsm('api'), }), - code, - ) + }) if (transformedCode?.code) { return { diff --git a/packages/internal/src/build/vercel.ts b/packages/internal/src/build/vercel.ts index 08ed3e20af6b..aed92bc29fc0 100644 --- a/packages/internal/src/build/vercel.ts +++ b/packages/internal/src/build/vercel.ts @@ -2,6 +2,70 @@ import type { types } from '@babel/core' import { parse, traverse, template } from '@babel/core' import generate from '@babel/generator' +export const validateFluidHandler = (code: string, filePath: string): void => { + const ast = parse(code, { + sourceType: 'module', + plugins: [ + ['@babel/plugin-transform-typescript', { isTSX: true }], + ['@babel/plugin-syntax-jsx', {}], + ], + }) + + if (!ast) { + return + } + + let hasLegacyHandler = false + let hasFluidHandlers = false + + traverse(ast, { + ExportNamedDeclaration(path) { + if ( + path.node.declaration?.type === 'VariableDeclaration' && + path.node.declaration.declarations[0].id.type === 'Identifier' && + path.node.declaration.declarations[0].id.name === 'handler' + ) { + hasLegacyHandler = true + } + + const httpMethods = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', + 'HEAD', + ] + if ( + path.node.declaration?.type === 'VariableDeclaration' && + path.node.declaration.declarations[0].id.type === 'Identifier' && + httpMethods.includes(path.node.declaration.declarations[0].id.name) + ) { + hasFluidHandlers = true + } + }, + ExportDefaultDeclaration() { + hasFluidHandlers = true + }, + }) + + if (hasLegacyHandler && !hasFluidHandlers) { + throw new Error( + `Vercel Fluid mode requires Request/Response handlers.\n\n` + + `Found legacy Lambda handler in: ${filePath}\n\n` + + `Fluid functions must export named HTTP method handlers (GET, POST, etc.) or a default handler:\n\n` + + ` export const GET = async (request: Request) => {\n` + + ` return new Response(JSON.stringify({ data: 'example' }), {\n` + + ` status: 200,\n` + + ` headers: { 'Content-Type': 'application/json' },\n` + + ` })\n` + + ` }\n\n` + + `For migration guide, see: https://redwoodjs.com/docs/vercel-fluid`, + ) + } +} + export const wrapVercelHandler = (code: string) => { const ast = parse(code, { sourceType: 'module', @@ -28,22 +92,23 @@ export const wrapVercelHandler = (code: string) => { path.node.declaration.declarations[0].id.name = '_redwoodHandler' // Create the new wrapped handler - const wrappedHandler = template.ast(` - import { webRequestToLambdaEvent, lambdaResponseToWebResponse } from '@redwoodjs/api/vercel' + const wrappedHandlerCode = ` export const handler = async (req, context) => { const event = await webRequestToLambdaEvent(req, context) const response = await _redwoodHandler(event, context) return lambdaResponseToWebResponse(response) } - `) + ` + const wrappedHandler = template.ast( + wrappedHandlerCode, + ) as types.Statement // Add the new handler after the renamed one path.insertAfter(wrappedHandler) // Add the import statement at the top of the file - const importStatement = template.ast(` - import { webRequestToLambdaEvent, lambdaResponseToWebResponse } from '@redwoodjs/api/vercel' - `) + const importCode = `import { webRequestToLambdaEvent, lambdaResponseToWebResponse } from '@redwoodjs/api/vercel'` + const importStatement = template.ast(importCode) as types.Statement const program = path.findParent((p) => p.isProgram()) if (program) { ;(program.node as types.Program).body.unshift(importStatement) diff --git a/packages/project-config/src/config.ts b/packages/project-config/src/config.ts index 991aec7f5a69..d38ddee21ad5 100644 --- a/packages/project-config/src/config.ts +++ b/packages/project-config/src/config.ts @@ -73,6 +73,15 @@ interface StudioConfig { graphiql?: GraphiQLStudioConfig } +interface VercelDeployConfig { + fluid?: boolean +} + +interface DeployConfig { + target?: string + vercel?: VercelDeployConfig +} + export interface Config { web: BrowserTargetConfig api: NodeTargetConfig @@ -95,6 +104,7 @@ export interface Config { versionUpdates: string[] } studio: StudioConfig + deploy?: DeployConfig experimental: { opentelemetry: { enabled: boolean