diff --git a/.changeset/modern-camels-appear.md b/.changeset/modern-camels-appear.md new file mode 100644 index 000000000..89ebaf8e1 --- /dev/null +++ b/.changeset/modern-camels-appear.md @@ -0,0 +1,5 @@ +--- +'@astrojs/vercel': patch +--- + +Support node-fetch and Node 18 fetch diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index e41d0a438..daa811015 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -3,12 +3,21 @@ import type { SSRManifest } from 'astro'; import { App } from 'astro/app'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { getRequest, setResponse } from './request-transform.js'; +import * as requestTransformLegacy from './request-transform/legacy.js'; +import * as requestTransformNode18 from './request-transform/node18.js'; polyfill(globalThis, { exclude: 'window document', }); +// Node 18+ has a new API for request/response, while older versions use node-fetch +// When we drop support for Node 14, we can remove the legacy code by switching to undici + +const nodeVersion = parseInt(process.version.split('.')[0].slice(1)); // 'v14.17.0' -> 14 + +const { getRequest, setResponse } = + nodeVersion >= 18 ? requestTransformNode18 : requestTransformLegacy; + export const createExports = (manifest: SSRManifest) => { const app = new App(manifest); diff --git a/packages/integrations/vercel/src/serverless/request-transform/legacy.ts b/packages/integrations/vercel/src/serverless/request-transform/legacy.ts new file mode 100644 index 000000000..7212431c7 --- /dev/null +++ b/packages/integrations/vercel/src/serverless/request-transform/legacy.ts @@ -0,0 +1,111 @@ +import type { App } from 'astro/app'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; + +const clientAddressSymbol = Symbol.for('astro.clientAddress'); + +/* + Credits to the SvelteKit team + https://github.com/sveltejs/kit/blob/69913e9fda054fa6a62a80e2bb4ee7dca1005796/packages/kit/src/node.js +*/ + +function get_raw_body(req: IncomingMessage) { + return new Promise((fulfil, reject) => { + const h = req.headers; + + if (!h['content-type']) { + return fulfil(null); + } + + req.on('error', reject); + + const length = Number(h['content-length']); + + // https://github.com/jshttp/type-is/blob/c1f4388c71c8a01f79934e68f630ca4a15fffcd6/index.js#L81-L95 + if (isNaN(length) && h['transfer-encoding'] == null) { + return fulfil(null); + } + + let data = new Uint8Array(length || 0); + + if (length > 0) { + let offset = 0; + req.on('data', (chunk) => { + const new_len = offset + Buffer.byteLength(chunk); + + if (new_len > length) { + return reject({ + status: 413, + reason: 'Exceeded "Content-Length" limit', + }); + } + + data.set(chunk, offset); + offset = new_len; + }); + } else { + req.on('data', (chunk) => { + const new_data = new Uint8Array(data.length + chunk.length); + new_data.set(data, 0); + new_data.set(chunk, data.length); + data = new_data; + }); + } + + req.on('end', () => { + fulfil(data); + }); + }); +} + +export async function getRequest(base: string, req: IncomingMessage): Promise { + let headers = req.headers as Record; + if (req.httpVersionMajor === 2) { + // we need to strip out the HTTP/2 pseudo-headers because node-fetch's + // Request implementation doesn't like them + headers = Object.assign({}, headers); + delete headers[':method']; + delete headers[':path']; + delete headers[':authority']; + delete headers[':scheme']; + } + const request = new Request(base + req.url, { + method: req.method, + headers, + body: await get_raw_body(req), // TODO stream rather than buffer + }); + Reflect.set(request, clientAddressSymbol, headers['x-forwarded-for']); + return request; +} + +export async function setResponse( + app: App, + res: ServerResponse, + response: Response +): Promise { + const headers = Object.fromEntries(response.headers); + + if (response.headers.has('set-cookie')) { + // @ts-expect-error (headers.raw() is non-standard) + headers['set-cookie'] = response.headers.raw()['set-cookie']; + } + + if (app.setCookieHeaders) { + const setCookieHeaders: Array = Array.from(app.setCookieHeaders(response)); + if (setCookieHeaders.length) { + res.setHeader('Set-Cookie', setCookieHeaders); + } + } + + res.writeHead(response.status, headers); + + if (response.body instanceof Readable) { + response.body.pipe(res); + } else { + if (response.body) { + res.write(await response.arrayBuffer()); + } + + res.end(); + } +} diff --git a/packages/integrations/vercel/src/serverless/request-transform.ts b/packages/integrations/vercel/src/serverless/request-transform/node18.ts similarity index 100% rename from packages/integrations/vercel/src/serverless/request-transform.ts rename to packages/integrations/vercel/src/serverless/request-transform/node18.ts