UNPKG

@astrojs/vercel

Version:
580 lines (579 loc) 20.2 kB
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { basename } from "node:path"; import { pathToFileURL } from "node:url"; import { emptyDir, removeDir, writeJson } from "@astrojs/internal-helpers/fs"; import { getTransformedRoutes, normalizeRoutes } from "@vercel/routing-utils"; import { AstroError } from "astro/errors"; import { globSync } from "tinyglobby"; import { getAstroImageConfig, getDefaultImageConfig } from "./image/shared.js"; import { copyDependenciesToFunction } from "./lib/nft.js"; import { escapeRegex, getRedirects } from "./lib/redirects.js"; import { getInjectableWebAnalyticsContent } from "./lib/web-analytics.js"; import { generateEdgeMiddleware } from "./serverless/middleware.js"; const PACKAGE_NAME = "@astrojs/vercel"; const ASTRO_PATH_HEADER = "x-astro-path"; const ASTRO_PATH_PARAM = "x_astro_path"; const ASTRO_LOCALS_HEADER = "x-astro-locals"; const ASTRO_MIDDLEWARE_SECRET_HEADER = "x-astro-middleware-secret"; const VERCEL_EDGE_MIDDLEWARE_FILE = "vercel-edge-middleware"; const NODE_PATH = "_render"; const MIDDLEWARE_PATH = "_middleware"; const ISR_PATH = `/_isr?${ASTRO_PATH_PARAM}=$0`; const SUPPORTED_NODE_VERSIONS = { 18: { status: "retiring", removal: /* @__PURE__ */ new Date("September 1 2025"), warnDate: /* @__PURE__ */ new Date("October 1 2024") }, 20: { status: "available" }, 22: { status: "default" } }; function getAdapter({ edgeMiddleware, middlewareSecret, skewProtection, buildOutput, experimentalStaticHeaders }) { return { name: PACKAGE_NAME, serverEntrypoint: `${PACKAGE_NAME}/entrypoint`, exports: ["default"], args: { middlewareSecret, skewProtection }, adapterFeatures: { edgeMiddleware, buildOutput, experimentalStaticHeaders }, supportedAstroFeatures: { hybridOutput: "stable", staticOutput: "stable", serverOutput: "stable", sharpImageService: "stable", i18nDomains: "experimental", envGetSecret: "stable" } }; } function vercelAdapter({ webAnalytics, includeFiles: _includeFiles = [], excludeFiles: _excludeFiles = [], imageService, imagesConfig, devImageService = "sharp", edgeMiddleware = false, maxDuration, isr = false, skewProtection = false, experimentalStaticHeaders = false } = {}) { if (maxDuration) { if (typeof maxDuration !== "number") { throw new TypeError(`maxDuration must be a number`, { cause: maxDuration }); } if (maxDuration <= 0) { throw new TypeError(`maxDuration must be a positive number`, { cause: maxDuration }); } } let _config; let _buildTempFolder; let _serverEntry; let _entryPoints; let _middlewareEntryPoint; let _routeToHeaders = void 0; const extraFilesToInclude = []; const middlewareSecret = crypto.randomUUID(); let _buildOutput; let staticDir; let routes; return { name: PACKAGE_NAME, hooks: { "astro:config:setup": async ({ command, config, updateConfig, injectScript, logger }) => { if (webAnalytics?.enabled) { injectScript( "head-inline", await getInjectableWebAnalyticsContent({ mode: command === "dev" ? "development" : "production" }) ); } staticDir = new URL("./.vercel/output/static", config.root); updateConfig({ build: { format: "directory", redirects: false }, integrations: [ { name: "astro:copy-vercel-output", hooks: { "astro:build:done": async () => { logger.info("Copying static files to .vercel/output/static"); const _staticDir = _buildOutput === "static" ? _config.outDir : _config.build.client; cpSync(_staticDir, new URL("./.vercel/output/static/", _config.root), { recursive: true }); } } } ], vite: { ssr: { external: ["@vercel/nft"] } }, ...getAstroImageConfig( imageService, imagesConfig, command, devImageService, config.image ) }); }, "astro:routes:resolved": (params) => { routes = params.routes; }, "astro:config:done": ({ setAdapter, config, logger, buildOutput }) => { _buildOutput = buildOutput; if (_buildOutput === "server") { if (maxDuration && maxDuration > 900) { logger.warn( `maxDuration is set to ${maxDuration} seconds, which is longer than the maximum allowed duration of 900 seconds.` ); logger.warn( `Please make sure that your plan allows for this duration. See https://vercel.com/docs/functions/serverless-functions/runtimes#maxduration for more information.` ); } const vercelConfigPath = new URL("vercel.json", config.root); if (config.trailingSlash && config.trailingSlash !== "ignore" && existsSync(vercelConfigPath)) { try { const vercelConfig = JSON.parse(readFileSync(vercelConfigPath, "utf-8")); if (vercelConfig.trailingSlash === true && config.trailingSlash === "never" || vercelConfig.trailingSlash === false && config.trailingSlash === "always") { logger.error( ` Your "vercel.json" \`trailingSlash\` configuration (set to \`${vercelConfig.trailingSlash}\`) will conflict with your Astro \`trailingSlash\` configuration (set to \`${JSON.stringify(config.trailingSlash)}\`). This would cause infinite redirects or duplicate content issues. Please remove the \`trailingSlash\` configuration from your \`vercel.json\` file or Astro config. ` ); } } catch (_err) { logger.warn(`Your "vercel.json" config is not a valid json file.`); } } setAdapter( getAdapter({ buildOutput: _buildOutput, edgeMiddleware, middlewareSecret, skewProtection, experimentalStaticHeaders }) ); } else { setAdapter( getAdapter({ edgeMiddleware: false, middlewareSecret: "", skewProtection, buildOutput: _buildOutput, experimentalStaticHeaders }) ); } _config = config; _buildTempFolder = config.build.server; _serverEntry = config.build.serverEntry; }, "astro:build:start": async () => { await emptyDir(new URL("./.vercel/output/", _config.root)); }, "astro:build:ssr": async ({ entryPoints, middlewareEntryPoint }) => { _entryPoints = new Map( Array.from(entryPoints).filter(([routeData]) => !routeData.prerender).map(([routeData, url]) => [ { entrypoint: routeData.component, patternRegex: routeData.pattern }, url ]) ); _middlewareEntryPoint = middlewareEntryPoint; }, "astro:build:generated": ({ experimentalRouteToHeaders }) => { _routeToHeaders = experimentalRouteToHeaders; }, "astro:build:done": async ({ logger }) => { const outDir = new URL("./.vercel/output/", _config.root); if (staticDir) { if (existsSync(staticDir)) { emptyDir(staticDir); } mkdirSync(new URL("./.vercel/output/static/", _config.root), { recursive: true }); mkdirSync(new URL("./.vercel/output/server/", _config.root)); if (_buildOutput !== "static") { cpSync(_config.build.server, new URL("./.vercel/output/_functions/", _config.root), { recursive: true }); } } const routeDefinitions = []; if (_buildOutput === "server") { if (_config.vite.assetsInclude) { const mergeGlobbedIncludes = (globPattern) => { if (typeof globPattern === "string") { const entries = globSync(globPattern).map((p) => pathToFileURL(p)); extraFilesToInclude.push(...entries); } else if (Array.isArray(globPattern)) { for (const pattern of globPattern) { mergeGlobbedIncludes(pattern); } } }; mergeGlobbedIncludes(_config.vite.assetsInclude); } const includeFiles = _includeFiles.map((file) => new URL(file, _config.root)).concat(extraFilesToInclude); const excludeFiles = _excludeFiles.map((file) => new URL(file, _config.root)); const builder = new VercelBuilder( _config, excludeFiles, includeFiles, logger, outDir, maxDuration ); if (_entryPoints.size) { const getRouteFuncName = (route) => route.entrypoint.replace("src/pages/", ""); const getFallbackFuncName = (entryFile) => basename(entryFile.toString()).replace("entry.", "").replace(/\.mjs$/, ""); for (const [route, entryFile] of _entryPoints) { const func = route.entrypoint.startsWith("src/pages/") ? getRouteFuncName(route) : getFallbackFuncName(entryFile); await builder.buildServerlessFolder(entryFile, func, _config.root); routeDefinitions.push({ src: route.patternRegex.source, dest: func }); } } else { const entryFile = new URL(_serverEntry, _buildTempFolder); if (isr) { const isrConfig = typeof isr === "object" ? isr : {}; await builder.buildServerlessFolder(entryFile, NODE_PATH, _config.root); if (isrConfig.exclude?.length) { const expandedExclusions = isrConfig.exclude.reduce((acc, exclusion) => { if (exclusion instanceof RegExp) { return [ ...acc, ...routes.filter((route) => exclusion.test(route.pattern)).map((route) => route.pattern) ]; } return [...acc, exclusion]; }, []); const dest = _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH; for (const route of expandedExclusions) { routeDefinitions.push({ src: escapeRegex(route), dest }); } } await builder.buildISRFolder(entryFile, "_isr", isrConfig, _config.root); for (const route of routes) { const excludeRouteFromIsr = isrConfig.exclude?.some((exclusion) => { if (exclusion instanceof RegExp) { return exclusion.test(route.pattern); } return exclusion === route.pattern; }); if (!excludeRouteFromIsr) { const src = route.patternRegex.source; const dest = src.startsWith("^\\/_image") || src.startsWith("^\\/_server-islands") ? NODE_PATH : ISR_PATH; if (!route.isPrerendered) routeDefinitions.push({ src, dest }); } } } else { await builder.buildServerlessFolder(entryFile, NODE_PATH, _config.root); const dest = _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH; for (const route of routes) { if (!route.isPrerendered) routeDefinitions.push({ src: route.patternRegex.source, dest }); } } } if (_middlewareEntryPoint) { await builder.buildMiddlewareFolder( _middlewareEntryPoint, MIDDLEWARE_PATH, middlewareSecret ); } } const fourOhFourRoute = routes.find((route) => route.pathname === "/404"); const vercelConfigJson = new URL("./.vercel/output/config.json", _config.root); const finalRoutes = [ { src: `^/${_config.build.assets}/(.*)$`, headers: { "cache-control": "public, max-age=31536000, immutable" }, continue: true } ]; if (_buildOutput === "server") { finalRoutes.push(...routeDefinitions); } if (fourOhFourRoute) { if (_buildOutput === "server") { finalRoutes.push({ src: "/.*", dest: fourOhFourRoute.isPrerendered ? "/404.html" : _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH, status: 404 }); } else { finalRoutes.push({ src: "/.*", dest: "/404.html", status: 404 }); } } let trailingSlash; if (_config.trailingSlash && _config.trailingSlash !== "ignore") { trailingSlash = _config.trailingSlash === "always"; } const { routes: redirects = [], error } = getTransformedRoutes({ trailingSlash, rewrites: [], redirects: getRedirects(routes, _config), headers: [] }); if (error) { throw new AstroError( `Error generating redirects: ${error.message}`, error.link ? `${error.action ?? "More info"}: ${error.link}` : void 0 ); } let images; if (imagesConfig) { images = { ...imagesConfig, domains: imagesConfig.domains || _config.image.domains ? [...imagesConfig.domains ?? [], ..._config.image.domains ?? []] : void 0, remotePatterns: [...imagesConfig.remotePatterns ?? []] }; const remotePatterns = _config.image.remotePatterns; for (const pattern of remotePatterns) { if (isAcceptedPattern(pattern)) { images.remotePatterns?.push(pattern); } } } else if (imageService) { images = getDefaultImageConfig(_config.image); } const normalized = normalizeRoutes([...redirects ?? [], ...finalRoutes]); if (normalized.error) { throw new AstroError( `Error generating routes: ${normalized.error.message}`, normalized.error.link ? `${normalized.error.action ?? "More info"}: ${normalized.error.link}` : void 0 ); } let headers = void 0; if (_routeToHeaders && _routeToHeaders.size > 0) { headers = createConfigHeaders(_routeToHeaders, _config); } await writeJson(vercelConfigJson, { version: 3, routes: normalized.routes, images, headers }); if (_buildOutput === "server") { await removeDir(_buildTempFolder); } } } }; } function isAcceptedPattern(pattern) { if (pattern == null) { return false; } if (!pattern.hostname) { return false; } if (pattern.protocol && (pattern.protocol !== "http" || pattern.protocol !== "https")) { return false; } return true; } class VercelBuilder { constructor(config, excludeFiles, includeFiles, logger, outDir, maxDuration, runtime = getRuntime(process, logger)) { this.config = config; this.excludeFiles = excludeFiles; this.includeFiles = includeFiles; this.logger = logger; this.outDir = outDir; this.maxDuration = maxDuration; this.runtime = runtime; } NTF_CACHE = {}; async buildServerlessFolder(entry, functionName, root) { const { includeFiles, excludeFiles, logger, NTF_CACHE, runtime, maxDuration } = this; const functionFolder = new URL(`./functions/${functionName}.func/`, this.outDir); const packageJson = new URL(`./functions/${functionName}.func/package.json`, this.outDir); const vcConfig = new URL(`./functions/${functionName}.func/.vc-config.json`, this.outDir); const { handler } = await copyDependenciesToFunction( { entry, outDir: functionFolder, includeFiles, excludeFiles, logger, root }, NTF_CACHE ); await writeJson(packageJson, { type: "module" }); await writeJson(vcConfig, { runtime, handler: handler.replaceAll("\\", "/"), launcherType: "Nodejs", maxDuration, supportsResponseStreaming: true }); } async buildISRFolder(entry, functionName, isr, root) { await this.buildServerlessFolder(entry, functionName, root); const prerenderConfig = new URL( `./functions/${functionName}.prerender-config.json`, this.outDir ); await writeJson(prerenderConfig, { expiration: isr.expiration ?? false, bypassToken: isr.bypassToken, allowQuery: [ASTRO_PATH_PARAM], passQuery: true }); } async buildMiddlewareFolder(entry, functionName, middlewareSecret) { const functionFolder = new URL(`./functions/${functionName}.func/`, this.outDir); await generateEdgeMiddleware( entry, this.config.root, new URL(VERCEL_EDGE_MIDDLEWARE_FILE, this.config.srcDir), new URL("./middleware.mjs", functionFolder), middlewareSecret, this.logger ); await writeJson(new URL(`./.vc-config.json`, functionFolder), { runtime: "edge", entrypoint: "middleware.mjs" }); } } function getRuntime(process2, logger) { const version = process2.version.slice(1); const major = version.split(".")[0]; const support = SUPPORTED_NODE_VERSIONS[major]; if (support === void 0) { logger.warn( ` The local Node.js version (${major}) is not supported by Vercel Serverless Functions. Your project will use Node.js 22 as the runtime instead. Consider switching your local version to 22. ` ); return "nodejs22.x"; } if (support.status === "default" || support.status === "available") { return `nodejs${major}.x`; } if (support.status === "retiring") { if (support.warnDate && /* @__PURE__ */ new Date() >= support.warnDate) { logger.warn( `Your project is being built for Node.js ${major} as the runtime, which is retiring by ${support.removal}.` ); } return `nodejs${major}.x`; } 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.` ); return `nodejs${major}.x`; } if (support.status === "deprecated") { const removeDate = new Intl.DateTimeFormat(void 0, { dateStyle: "long" }).format(support.removal); logger.warn( ` Your project is being built for Node.js ${major} as the runtime. This version is deprecated by Vercel Serverless Functions, and scheduled to be disabled on ${removeDate}. Consider upgrading your local version to 22. ` ); return `nodejs${major}.x`; } return "nodejs22.x"; } function createConfigHeaders(staticHeaders, config) { const vercelHeaders = []; for (const [pathname, { headers }] of staticHeaders.entries()) { if (config.experimental.csp) { const csp = headers.get("Content-Security-Policy"); if (csp) { vercelHeaders.push({ source: pathname, headers: [ { key: "Content-Security-Policy", value: csp } ] }); } } } return vercelHeaders; } export { ASTRO_LOCALS_HEADER, ASTRO_MIDDLEWARE_SECRET_HEADER, ASTRO_PATH_HEADER, ASTRO_PATH_PARAM, NODE_PATH, VERCEL_EDGE_MIDDLEWARE_FILE, vercelAdapter as default };