From 34f265066c61ddca5f906415ebb6b04410c62079 Mon Sep 17 00:00:00 2001 From: lilnasy <69170106+lilnasy@users.noreply.github.com> Date: Sat, 23 Dec 2023 13:43:53 +0000 Subject: [PATCH 1/3] refactor --- .../vercel/src/serverless/adapter.ts | 95 ++++---- .../vercel/src/serverless/entrypoint.ts | 34 +-- .../src/serverless/request-transform.ts | 203 ------------------ 3 files changed, 60 insertions(+), 272 deletions(-) delete mode 100644 packages/integrations/vercel/src/serverless/request-transform.ts diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 3e4934bc21ee..5a3c92b02abb 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -112,7 +112,7 @@ export default function vercelServerless({ webAnalytics, speedInsights, includeFiles, - excludeFiles, + excludeFiles = [], imageService, imagesConfig, devImageService = 'sharp', @@ -189,9 +189,10 @@ export default function vercelServerless({ 'astro:config:done': ({ setAdapter, config, logger }) => { if (functionPerRoute === true) { logger.warn( - `Vercel's hosting plans might have limits to the number of functions you can create. -Make sure to check your plan carefully to avoid incurring additional costs. -You can set functionPerRoute: false to prevent surpassing the limit.` + `\n` + + `\tVercel's hosting plans might have limits to the number of functions you can create.\n` + + `\tMake sure to check your plan carefully to avoid incurring additional costs.\n` + + `\tYou can set functionPerRoute: false to prevent surpassing the limit.\n` ); } setAdapter(getAdapter({ functionPerRoute, edgeMiddleware })); @@ -205,7 +206,6 @@ You can set functionPerRoute: false to prevent surpassing the limit.` ); } }, - 'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => { _entryPoints = entryPoints; if (middlewareEntryPoint) { @@ -223,7 +223,6 @@ You can set functionPerRoute: false to prevent surpassing the limit.` extraFilesToInclude.push(bundledMiddlewarePath); } }, - 'astro:build:done': async ({ routes, logger }) => { // Merge any includes from `vite.assetsInclude if (_config.vite.assetsInclude) { @@ -245,7 +244,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.` const filesToInclude = includeFiles?.map((file) => new URL(file, _config.root)) || []; filesToInclude.push(...extraFilesToInclude); - validateRuntime(); + const runtime = getRuntime(process, logger); // Multiple entrypoint support if (_entryPoints.size) { @@ -263,6 +262,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.` await createFunctionFolder({ functionName: func, + runtime, entry: entryFile, config: _config, logger, @@ -279,6 +279,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.` } else { await createFunctionFolder({ functionName: 'render', + runtime, entry: new URL(serverEntry, buildTempFolder), config: _config, logger, @@ -342,19 +343,23 @@ You can set functionPerRoute: false to prevent surpassing the limit.` }; } +type Runtime = `nodejs${string}.x`; + interface CreateFunctionFolderArgs { functionName: string; + runtime: Runtime; entry: URL; config: AstroConfig; logger: AstroIntegrationLogger; NTF_CACHE: any; includeFiles: URL[]; - excludeFiles?: string[]; + excludeFiles: string[]; maxDuration: number | undefined; } async function createFunctionFolder({ functionName, + runtime, entry, config, logger, @@ -363,7 +368,10 @@ async function createFunctionFolder({ excludeFiles, maxDuration, }: CreateFunctionFolderArgs) { + // .vercel/output/functions/.func/ const functionFolder = new URL(`./functions/${functionName}.func/`, config.outDir); + const packageJson = new URL(`./functions/${functionName}.func/package.json`, config.outDir); + const vcConfig = new URL(`./functions/${functionName}.func/.vc-config.json`, config.outDir); // Copy necessary files (e.g. node_modules/) const { handler } = await copyDependenciesToFunction( @@ -371,7 +379,7 @@ async function createFunctionFolder({ entry, outDir: functionFolder, includeFiles, - excludeFiles: excludeFiles?.map((file) => new URL(file, config.root)) || [], + excludeFiles: excludeFiles.map((file) => new URL(file, config.root)), logger, }, NTF_CACHE @@ -379,14 +387,12 @@ async function createFunctionFolder({ // Enable ESM // https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda/ - await writeJson(new URL(`./package.json`, functionFolder), { - type: 'module', - }); + await writeJson(packageJson, { type: 'module' }); // Serverless function config // https://vercel.com/docs/build-output-api/v3#vercel-primitives/serverless-functions/configuration - await writeJson(new URL(`./.vc-config.json`, functionFolder), { - runtime: getRuntime(), + await writeJson(vcConfig, { + runtime, handler, launcherType: 'Nodejs', maxDuration, @@ -394,44 +400,43 @@ async function createFunctionFolder({ }); } -function validateRuntime() { - const version = process.version.slice(1); // 'v16.5.0' --> '16.5.0' - const major = version.split('.')[0]; // '16.5.0' --> '16' +function getRuntime(process: NodeJS.Process, logger: AstroIntegrationLogger): Runtime { + const version = process.version.slice(1); // 'v18.19.0' --> '18.19.0' + const major = version.split('.')[0]; // '18.19.0' --> '18' const support = SUPPORTED_NODE_VERSIONS[major]; if (support === undefined) { - console.warn( - `[${PACKAGE_NAME}] The local Node.js version (${major}) is not supported by Vercel Serverless Functions.` + logger.warn( + `\n` + + `\tThe local Node.js version (${major}) is not supported by Vercel Serverless Functions.\n` + + `\tYour project will use Node.js 18 as the runtime instead.\n` + + `\tConsider switching your local version to 18.\n` ); - console.warn(`[${PACKAGE_NAME}] Your project will use Node.js 18 as the runtime instead.`); - console.warn(`[${PACKAGE_NAME}] Consider switching your local version to 18.`); - return; } - if (support.status === 'beta') { - console.warn( - `[${PACKAGE_NAME}] The local Node.js version (${major}) is currently in beta for Vercel Serverless Functions.` + if (support.status === 'current') { + return `nodejs${major}.x`; + } else if (support?.status === 'beta') { + logger.warn( + `Your project is being built for Node.js ${major} as the runtime, which is currently in beta for Vercel Serverless Functions.` ); - console.warn(`[${PACKAGE_NAME}] Make sure to update your Vercel settings to use ${major}.`); - return; - } - if (support.status === 'deprecated') { - console.warn( - `[${PACKAGE_NAME}] Your project is being built for Node.js ${major} as the runtime.` + return `nodejs${major}.x`; + } else if (support.status === 'deprecated') { + const removeDate = new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }).format( + support.removal ); - console.warn( - `[${PACKAGE_NAME}] This version is deprecated by Vercel Serverless Functions, and scheduled to be disabled on ${new Intl.DateTimeFormat( - undefined, - { dateStyle: 'long' } - ).format(support.removal)}.` + logger.warn( + `\n` + + `\tYour project is being built for Node.js ${major} as the runtime.\n` + + `\tThis version is deprecated by Vercel Serverless Functions, and scheduled to be disabled on ${removeDate}.\n` + + `\tConsider upgrading your local version to 18.\n` + ); + return `nodejs${major}.x`; + } else { + logger.warn( + `\n` + + `\tThe local Node.js version (${major}) is not supported by Vercel Serverless Functions.\n` + + `\tYour project will use Node.js 18 as the runtime instead.\n` + + `\tConsider switching your local version to 18.\n` ); - console.warn(`[${PACKAGE_NAME}] Consider upgrading your local version to 18.`); - } -} - -function getRuntime() { - const version = process.version.slice(1); // 'v16.5.0' --> '16.5.0' - const major = version.split('.')[0]; // '16.5.0' --> '16' - const support = SUPPORTED_NODE_VERSIONS[major]; - if (support === undefined) { return 'nodejs18.x'; } return `nodejs${major}.x`; diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index 513c34640af6..5d4c7cb21cce 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -1,35 +1,21 @@ import type { SSRManifest } from 'astro'; -import { App } from 'astro/app'; -import { applyPolyfills } from 'astro/app/node'; +import { applyPolyfills, NodeApp } from 'astro/app/node'; import type { IncomingMessage, ServerResponse } from 'node:http'; - import { ASTRO_LOCALS_HEADER } from './adapter.js'; -import { getRequest, setResponse } from './request-transform.js'; applyPolyfills(); export const createExports = (manifest: SSRManifest) => { - const app = new App(manifest); - + const app = new NodeApp(manifest); const handler = async (req: IncomingMessage, res: ServerResponse) => { - let request: Request; - - try { - request = await getRequest(`https://${req.headers.host}`, req); - } catch (err: any) { - res.statusCode = err.status || 400; - return res.end(err.reason || 'Invalid request body'); - } - - let routeData = app.match(request); - let locals = {}; - if (request.headers.has(ASTRO_LOCALS_HEADER)) { - let localsAsString = request.headers.get(ASTRO_LOCALS_HEADER); - if (localsAsString) { - locals = JSON.parse(localsAsString); - } - } - await setResponse(app, res, await app.render(request, { routeData, locals })); + const clientAddress = req.headers['x-forwarded-for'] as string | undefined; + const localsHeader = req.headers[ASTRO_LOCALS_HEADER] + const locals = + typeof localsHeader === "string" ? JSON.parse(localsHeader) + : Array.isArray(localsHeader) ? JSON.parse(localsHeader[0]) + : {}; + const webResponse = await app.render(req, { locals, clientAddress }) + await NodeApp.writeResponse(webResponse, res); }; return { default: handler }; diff --git a/packages/integrations/vercel/src/serverless/request-transform.ts b/packages/integrations/vercel/src/serverless/request-transform.ts deleted file mode 100644 index 31aa377af6cc..000000000000 --- a/packages/integrations/vercel/src/serverless/request-transform.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { App } from 'astro/app'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { splitCookiesString } from 'set-cookie-parser'; - -const clientAddressSymbol = Symbol.for('astro.clientAddress'); - -/* - Credits to the SvelteKit team - https://github.com/sveltejs/kit/blob/8d1ba04825a540324bc003e85f36559a594aadc2/packages/kit/src/exports/node/index.js -*/ - -function get_raw_body(req: IncomingMessage, body_size_limit?: number): ReadableStream | null { - const h = req.headers; - - if (!h['content-type']) { - return null; - } - - const content_length = Number(h['content-length']); - - // check if no request body - if ( - (req.httpVersionMajor === 1 && isNaN(content_length) && h['transfer-encoding'] == null) || - content_length === 0 - ) { - return null; - } - - let length = content_length; - - if (body_size_limit) { - if (!length) { - length = body_size_limit; - } else if (length > body_size_limit) { - throw new HTTPError( - 413, - `Received content-length of ${length}, but only accept up to ${body_size_limit} bytes.` - ); - } - } - - if (req.destroyed) { - const readable = new ReadableStream(); - readable.cancel(); - return readable; - } - - let size = 0; - let cancelled = false; - - return new ReadableStream({ - start(controller) { - req.on('error', (error) => { - cancelled = true; - controller.error(error); - }); - - req.on('end', () => { - if (cancelled) return; - controller.close(); - }); - - req.on('data', (chunk) => { - if (cancelled) return; - - size += chunk.length; - if (size > length) { - cancelled = true; - controller.error( - new HTTPError( - 413, - `request body size exceeded ${ - content_length ? "'content-length'" : 'BODY_SIZE_LIMIT' - } of ${length}` - ) - ); - return; - } - - controller.enqueue(chunk); - - if (controller.desiredSize === null || controller.desiredSize <= 0) { - req.pause(); - } - }); - }, - - pull() { - req.resume(); - }, - - cancel(reason) { - cancelled = true; - req.destroy(reason); - }, - }); -} - -export async function getRequest( - base: string, - req: IncomingMessage, - bodySizeLimit?: number -): Promise { - let headers = req.headers as Record; - let request = new Request(base + req.url, { - // @ts-expect-error -- duplex does exist in Vercel requests - duplex: 'half', - method: req.method, - headers, - body: get_raw_body(req, bodySizeLimit), - }); - 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); - let cookies: string[] = []; - - if (response.headers.has('set-cookie')) { - const header = response.headers.get('set-cookie')!; - const split = splitCookiesString(header); - cookies = split; - } - - if (app.setCookieHeaders) { - for (const setCookieHeader of app.setCookieHeaders(response)) { - cookies.push(setCookieHeader); - } - } - - res.writeHead(response.status, { ...headers, 'set-cookie': cookies }); - - if (!response.body) { - res.end(); - return; - } - - if (response.body.locked) { - res.write( - 'Fatal error: Response body is locked. ' + - `This can happen when the response was already read (for example through 'response.json()' or 'response.text()').` - ); - res.end(); - return; - } - - const reader = response.body.getReader(); - - if (res.destroyed) { - reader.cancel(); - return; - } - - const cancel = (error?: Error) => { - res.off('close', cancel); - res.off('error', cancel); - - // If the reader has already been interrupted with an error earlier, - // then it will appear here, it is useless, but it needs to be catch. - reader.cancel(error).catch(() => {}); - if (error) res.destroy(error); - }; - - res.on('close', cancel); - res.on('error', cancel); - - next(); - async function next() { - try { - for (;;) { - const { done, value } = await reader.read(); - - if (done) break; - - if (!res.write(value)) { - res.once('drain', next); - return; - } - } - res.end(); - } catch (error) { - cancel(error instanceof Error ? error : new Error(String(error))); - } - } -} - -class HTTPError extends Error { - status: number; - - constructor(status: number, reason: string) { - super(reason); - this.status = status; - } - - get reason() { - return super.message; - } -} From 623b0c4c3a571f1285b808dbc1e62e0554e5edab Mon Sep 17 00:00:00 2001 From: lilnasy <69170106+lilnasy@users.noreply.github.com> Date: Fri, 12 Jan 2024 16:36:34 +0000 Subject: [PATCH 2/3] add changeset --- .changeset/early-cups-poke.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/early-cups-poke.md diff --git a/.changeset/early-cups-poke.md b/.changeset/early-cups-poke.md new file mode 100644 index 000000000000..d4f816dceef9 --- /dev/null +++ b/.changeset/early-cups-poke.md @@ -0,0 +1,7 @@ +--- +"@astrojs/vercel": major +--- + +**Breaking**: Minimum required Astro version is now 4.2.0. +Reorganizes internals to be more maintainable. +--- From 0b4e92fca7918bd9d99863d0e876eb4d50c253f6 Mon Sep 17 00:00:00 2001 From: lilnasy <69170106+lilnasy@users.noreply.github.com> Date: Fri, 12 Jan 2024 16:38:56 +0000 Subject: [PATCH 3/3] bump peer dependencies --- packages/integrations/node/package.json | 2 +- packages/integrations/vercel/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index c3d952840ad2..347eba0d696d 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -37,7 +37,7 @@ "server-destroy": "^1.0.1" }, "peerDependencies": { - "astro": "^4.0.0" + "astro": "^4.2.0" }, "devDependencies": { "@types/node": "^18.17.8", diff --git a/packages/integrations/vercel/package.json b/packages/integrations/vercel/package.json index 46a032465b05..b9b044600d2e 100644 --- a/packages/integrations/vercel/package.json +++ b/packages/integrations/vercel/package.json @@ -59,7 +59,7 @@ "web-vitals": "^3.4.0" }, "peerDependencies": { - "astro": "^4.0.2" + "astro": "^4.2.0" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.6",