diff --git a/packages/runtime/plugin-runtime/package.json b/packages/runtime/plugin-runtime/package.json index 3ff6c8535ed8..7c9c5215babd 100644 --- a/packages/runtime/plugin-runtime/package.json +++ b/packages/runtime/plugin-runtime/package.json @@ -119,7 +119,11 @@ }, "./cache": { "types": "./dist/types/cache/index.d.ts", - "default": "./dist/esm/cache/index.mjs" + "node": { + "import": "./dist/esm-node/cache/index.mjs", + "require": "./dist/cjs/cache/index.js" + }, + "default": "./dist/cjs/cache/index.js" }, "./config-routes": { "types": "./dist/types/exports/config-routes.d.ts", diff --git a/packages/server/prod-server/src/netlify.ts b/packages/server/prod-server/src/netlify.ts index f39d6270e65c..eb2f4ee11ae5 100644 --- a/packages/server/prod-server/src/netlify.ts +++ b/packages/server/prod-server/src/netlify.ts @@ -9,6 +9,137 @@ import type { BaseEnv, ProdServerOptions } from './types'; export type { ProdServerOptions, BaseEnv } from './types'; +type NetlifyEvent = { + body?: string | null; + headers?: Record; + httpMethod?: string; + isBase64Encoded?: boolean; + multiValueHeaders?: Record | undefined>; + path?: string; + queryStringParameters?: Record | null; + rawQuery?: string; + rawUrl?: string; +}; + +type NetlifyResponse = { + body: string; + headers: Record; + isBase64Encoded: boolean; + multiValueHeaders?: Record; + statusCode: number; +}; + +const isRequest = (value: unknown): value is Request => { + return value instanceof Request; +}; + +const isTextResponse = (contentType: string | null) => { + if (!contentType) { + return true; + } + + return ( + contentType.startsWith('text/') || + contentType.includes('application/json') || + contentType.includes('application/javascript') || + contentType.includes('application/xml') || + contentType.includes('application/x-www-form-urlencoded') || + contentType.includes('image/svg+xml') + ); +}; + +const getRequestUrl = (event: NetlifyEvent) => { + if (event.rawUrl) { + return event.rawUrl; + } + + const headers = event.headers ?? {}; + const protocol = headers['x-forwarded-proto'] ?? 'https'; + const host = headers['x-forwarded-host'] ?? headers.host ?? 'localhost'; + const pathname = event.path ?? '/'; + + if (event.rawQuery) { + return `${protocol}://${host}${pathname}?${event.rawQuery}`; + } + + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(event.queryStringParameters ?? {})) { + if (value !== undefined) { + search.append(key, value); + } + } + + const query = search.toString(); + return `${protocol}://${host}${pathname}${query ? `?${query}` : ''}`; +}; + +const createWebRequest = (event: NetlifyEvent): Request => { + const headers = new Headers(); + + for (const [key, values] of Object.entries(event.multiValueHeaders ?? {})) { + if (!values) { + continue; + } + + for (const value of values) { + if (value !== undefined) { + headers.append(key, value); + } + } + } + + for (const [key, value] of Object.entries(event.headers ?? {})) { + if (value !== undefined && !headers.has(key)) { + headers.set(key, value); + } + } + + const method = event.httpMethod ?? 'GET'; + const init: RequestInit & { duplex?: 'half' } = { + headers, + method, + }; + + if (!(method === 'GET' || method === 'HEAD') && event.body) { + init.body = event.isBase64Encoded + ? Buffer.from(event.body, 'base64') + : event.body; + init.duplex = 'half'; + } + + return new Request(getRequestUrl(event), init); +}; + +const createNetlifyResponse = async ( + response: Response, +): Promise => { + const headers: Record = {}; + for (const [key, value] of response.headers.entries()) { + if (key !== 'set-cookie') { + headers[key] = value; + } + } + + const setCookie = + typeof response.headers.getSetCookie === 'function' + ? response.headers.getSetCookie() + : []; + + const contentType = response.headers.get('content-type'); + const buffer = Buffer.from(await response.arrayBuffer()); + const isBase64Encoded = !isTextResponse(contentType); + + return { + statusCode: response.status, + headers, + body: isBase64Encoded ? buffer.toString('base64') : buffer.toString('utf8'), + isBase64Encoded, + ...(setCookie.length > 0 + ? { multiValueHeaders: { 'set-cookie': setCookie } } + : {}), + }; +}; + export const createNetlifyFunction = async (options: ProdServerOptions) => { await loadServerEnv(options); @@ -35,7 +166,14 @@ export const createNetlifyFunction = async (options: ProdServerOptions) => { await applyPlugins(server, options); await server.init(); - return (request: Request, context: unknown) => { - return server.handle(request, context); + return async (requestOrEvent: Request | NetlifyEvent, context: unknown) => { + if (isRequest(requestOrEvent)) { + return server.handle(requestOrEvent, context); + } + + const request = createWebRequest(requestOrEvent); + const response = await server.handle(request, context); + + return createNetlifyResponse(response); }; };