diff options
author | 2025-02-07 08:47:40 +0000 | |
---|---|---|
committer | 2025-02-07 08:47:40 +0000 | |
commit | efef4136e36b7b272f39ee9e1d173b44c212ec34 (patch) | |
tree | 8b87e07aff600b01dbba7f4cfaa8f8ddbfa557a6 /packages/integrations/vercel/src | |
parent | 4e7d97fb09f8180572fca5d823ad8edcda7b50b4 (diff) | |
parent | 64b118ac9558287c2da76247d171ae3a88d390e4 (diff) | |
download | astro-efef4136e36b7b272f39ee9e1d173b44c212ec34.tar.gz astro-efef4136e36b7b272f39ee9e1d173b44c212ec34.tar.zst astro-efef4136e36b7b272f39ee9e1d173b44c212ec34.zip |
Merge pull request #13147 from withastro/move-vercel
chore: move Vercel adapter to core monorepo
Diffstat (limited to 'packages/integrations/vercel/src')
-rw-r--r-- | packages/integrations/vercel/src/image/build-service.ts | 64 | ||||
-rw-r--r-- | packages/integrations/vercel/src/image/dev-service.ts | 31 | ||||
-rw-r--r-- | packages/integrations/vercel/src/image/shared-dev-service.ts | 35 | ||||
-rw-r--r-- | packages/integrations/vercel/src/image/shared.ts | 163 | ||||
-rw-r--r-- | packages/integrations/vercel/src/index.ts | 685 | ||||
-rw-r--r-- | packages/integrations/vercel/src/lib/nft.ts | 83 | ||||
-rw-r--r-- | packages/integrations/vercel/src/lib/redirects.ts | 131 | ||||
-rw-r--r-- | packages/integrations/vercel/src/lib/searchRoot.ts | 101 | ||||
-rw-r--r-- | packages/integrations/vercel/src/lib/web-analytics.ts | 30 | ||||
-rw-r--r-- | packages/integrations/vercel/src/serverless/adapter.ts | 10 | ||||
-rw-r--r-- | packages/integrations/vercel/src/serverless/entrypoint.ts | 58 | ||||
-rw-r--r-- | packages/integrations/vercel/src/serverless/middleware.ts | 132 | ||||
-rw-r--r-- | packages/integrations/vercel/src/serverless/polyfill.ts | 3 | ||||
-rw-r--r-- | packages/integrations/vercel/src/static/adapter.ts | 10 | ||||
-rw-r--r-- | packages/integrations/vercel/src/types.d.ts | 3 |
15 files changed, 1539 insertions, 0 deletions
diff --git a/packages/integrations/vercel/src/image/build-service.ts b/packages/integrations/vercel/src/image/build-service.ts new file mode 100644 index 000000000..e793b896e --- /dev/null +++ b/packages/integrations/vercel/src/image/build-service.ts @@ -0,0 +1,64 @@ +import type { ExternalImageService } from 'astro'; +import { baseService } from 'astro/assets'; +import { isESMImportedImage, sharedValidateOptions } from './shared.js'; + +const service: ExternalImageService = { + ...baseService, + validateOptions: (options, serviceOptions) => + sharedValidateOptions(options, serviceOptions.service.config, 'production'), + getHTMLAttributes(options) { + const { inputtedWidth, ...props } = options; + + // If `validateOptions` returned a different width than the one of the image, use it for attributes + if (inputtedWidth) { + props.width = inputtedWidth; + } + + let targetWidth = props.width; + let targetHeight = props.height; + if (isESMImportedImage(props.src)) { + const aspectRatio = props.src.width / props.src.height; + if (targetHeight && !targetWidth) { + // If we have a height but no width, use height to calculate the width + targetWidth = Math.round(targetHeight * aspectRatio); + } else if (targetWidth && !targetHeight) { + // If we have a width but no height, use width to calculate the height + targetHeight = Math.round(targetWidth / aspectRatio); + } else if (!targetWidth && !targetHeight) { + // If we have neither width or height, use the original image's dimensions + targetWidth = props.src.width; + targetHeight = props.src.height; + } + } + + const { src, width, height, format, quality, densities, widths, formats, ...attributes } = + options; + + return { + ...attributes, + width: targetWidth, + height: targetHeight, + loading: attributes.loading ?? 'lazy', + decoding: attributes.decoding ?? 'async', + }; + }, + getURL(options) { + const fileSrc = isESMImportedImage(options.src) + ? removeLeadingForwardSlash(options.src.src) + : options.src; + + const searchParams = new URLSearchParams(); + searchParams.append('url', fileSrc); + + options.width && searchParams.append('w', options.width.toString()); + options.quality && searchParams.append('q', options.quality.toString()); + + return '/_vercel/image?' + searchParams; + }, +}; + +function removeLeadingForwardSlash(path: string) { + return path.startsWith('/') ? path.substring(1) : path; +} + +export default service; diff --git a/packages/integrations/vercel/src/image/dev-service.ts b/packages/integrations/vercel/src/image/dev-service.ts new file mode 100644 index 000000000..c9702cff9 --- /dev/null +++ b/packages/integrations/vercel/src/image/dev-service.ts @@ -0,0 +1,31 @@ +import type { LocalImageService } from 'astro'; +import sharpService from 'astro/assets/services/sharp'; +import { baseDevService } from './shared-dev-service.js'; + +const service: LocalImageService = { + ...baseDevService, + getHTMLAttributes(options, serviceOptions) { + const { inputtedWidth, ...props } = options; + + // If `validateOptions` returned a different width than the one of the image, use it for attributes + if (inputtedWidth) { + props.width = inputtedWidth; + } + + return sharpService.getHTMLAttributes + ? sharpService.getHTMLAttributes(props, serviceOptions) + : {}; + }, + transform(inputBuffer, transform, serviceOptions) { + // NOTE: Hardcoding webp here isn't accurate to how the Vercel Image Optimization API works, normally what we should + // do is setup a custom endpoint that sniff the user's accept-content header and serve the proper format based on the + // user's Vercel config. However, that's: a lot of work for: not much. The dev service is inaccurate to the prod service + // in many more ways, this is one of the less offending cases and is, imo, okay, erika - 2023-04-27 + transform.format = transform.src.endsWith('svg') ? 'svg' : 'webp'; + + // The base sharp service works the same way as the Vercel Image Optimization API, so it's a safe fallback in local + return sharpService.transform(inputBuffer, transform, serviceOptions); + }, +}; + +export default service; diff --git a/packages/integrations/vercel/src/image/shared-dev-service.ts b/packages/integrations/vercel/src/image/shared-dev-service.ts new file mode 100644 index 000000000..ee6d8149d --- /dev/null +++ b/packages/integrations/vercel/src/image/shared-dev-service.ts @@ -0,0 +1,35 @@ +import type { LocalImageService } from 'astro'; +import { baseService } from 'astro/assets'; +import { sharedValidateOptions } from './shared.js'; + +export const baseDevService: Omit<LocalImageService, 'transform'> = { + ...baseService, + validateOptions: (options, serviceOptions) => + sharedValidateOptions(options, serviceOptions.service.config, 'development'), + getURL(options) { + const fileSrc = typeof options.src === 'string' ? options.src : options.src.src; + + const searchParams = new URLSearchParams(); + searchParams.append('href', fileSrc); + + options.width && searchParams.append('w', options.width.toString()); + options.quality && searchParams.append('q', options.quality.toString()); + + return '/_image?' + searchParams; + }, + parseURL(url) { + const params = url.searchParams; + + if (!params.has('href')) { + return undefined; + } + + const transform = { + src: params.get('href')!, + width: params.has('w') ? Number.parseInt(params.get('w')!) : undefined, + quality: params.get('q'), + }; + + return transform; + }, +}; diff --git a/packages/integrations/vercel/src/image/shared.ts b/packages/integrations/vercel/src/image/shared.ts new file mode 100644 index 000000000..e21b79e6a --- /dev/null +++ b/packages/integrations/vercel/src/image/shared.ts @@ -0,0 +1,163 @@ +import type { AstroConfig, ImageMetadata, ImageQualityPreset, ImageTransform } from 'astro'; + +export function getDefaultImageConfig(astroImageConfig: AstroConfig['image']): VercelImageConfig { + return { + sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + domains: astroImageConfig.domains ?? [], + // Cast is necessary here because Vercel's types are slightly different from ours regarding allowed protocols. Behavior should be the same, however. + remotePatterns: (astroImageConfig.remotePatterns as VercelImageConfig['remotePatterns']) ?? [], + }; +} + +export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata { + return typeof src === 'object'; +} + +export type DevImageService = 'sharp' | (string & {}); + +// https://vercel.com/docs/build-output-api/v3/configuration#images +type ImageFormat = 'image/avif' | 'image/webp'; + +export type RemotePattern = { + protocol?: 'http' | 'https'; + hostname: string; + port?: string; + pathname?: string; +}; + +export type VercelImageConfig = { + /** + * Supported image widths. + */ + sizes: number[]; + /** + * Allowed external domains that can use Image Optimization. Leave empty for only allowing the deployment domain to use Image Optimization. + */ + domains: string[]; + /** + * Allowed external patterns that can use Image Optimization. Similar to `domains` but provides more control with RegExp. + */ + remotePatterns?: RemotePattern[]; + /** + * Cache duration (in seconds) for the optimized images. + */ + minimumCacheTTL?: number; + /** + * Supported output image formats + */ + formats?: ImageFormat[]; + /** + * Allow SVG input image URLs. This is disabled by default for security purposes. + */ + dangerouslyAllowSVG?: boolean; + /** + * Change the [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) of the optimized images. + */ + contentSecurityPolicy?: string; +}; + +export const qualityTable: Record<ImageQualityPreset, number> = { + low: 25, + mid: 50, + high: 80, + max: 100, +}; + +export function getAstroImageConfig( + images: boolean | undefined, + imagesConfig: VercelImageConfig | undefined, + command: string, + devImageService: DevImageService, + astroImageConfig: AstroConfig['image'] +) { + let devService = '@astrojs/vercel/dev-image-service'; + + switch (devImageService) { + case 'sharp': + devService = '@astrojs/vercel/dev-image-service'; + break; + default: + if (typeof devImageService === 'string') { + devService = devImageService; + } else { + devService = '@astrojs/vercel/dev-image-service'; + } + break; + } + + if (images) { + return { + image: { + service: { + entrypoint: command === 'dev' ? devService : '@astrojs/vercel/build-image-service', + config: imagesConfig ? imagesConfig : getDefaultImageConfig(astroImageConfig), + }, + }, + }; + } + + return {}; +} + +export function sharedValidateOptions( + options: ImageTransform, + serviceConfig: Record<string, any>, + mode: 'development' | 'production' +) { + const vercelImageOptions = serviceConfig as VercelImageConfig; + + if ( + mode === 'development' && + (!vercelImageOptions.sizes || vercelImageOptions.sizes.length === 0) + ) { + throw new Error('Vercel Image Optimization requires at least one size to be configured.'); + } + + const configuredWidths = vercelImageOptions.sizes.sort((a, b) => a - b); + + // The logic for finding the perfect width is a bit confusing, here it goes: + // For images where no width has been specified: + // - For local, imported images, fallback to nearest width we can find in our configured + // - For remote images, that's an error, width is always required. + // For images where a width has been specified: + // - If the width that the user asked for isn't in `sizes`, then fallback to the nearest one, but save the width + // the user asked for so we can put it on the `img` tag later. + // - Otherwise, just use as-is. + // The end goal is: + // - The size on the page is always the one the user asked for or the base image's size + // - The actual size of the image file is always one of `sizes`, either the one the user asked for or the nearest to it + if (!options.width) { + const src = options.src; + if (isESMImportedImage(src)) { + const nearestWidth = configuredWidths.reduce((prev, curr) => { + return Math.abs(curr - src.width) < Math.abs(prev - src.width) ? curr : prev; + }); + + // Use the image's base width to inform the `width` and `height` on the `img` tag + options.inputtedWidth = src.width; + options.width = nearestWidth; + } else { + throw new Error(`Missing \`width\` parameter for remote image ${options.src}`); + } + } else { + if (!configuredWidths.includes(options.width)) { + const nearestWidth = configuredWidths.reduce((prev, curr) => { + return Math.abs(curr - options.width!) < Math.abs(prev - options.width!) ? curr : prev; + }); + + // Save the width the user asked for to inform the `width` and `height` on the `img` tag + options.inputtedWidth = options.width; + options.width = nearestWidth; + } + } + + if (options.quality && typeof options.quality === 'string') { + options.quality = options.quality in qualityTable ? qualityTable[options.quality] : undefined; + } + + if (!options.quality) { + options.quality = 100; + } + + return options; +} diff --git a/packages/integrations/vercel/src/index.ts b/packages/integrations/vercel/src/index.ts new file mode 100644 index 000000000..db61b0d3c --- /dev/null +++ b/packages/integrations/vercel/src/index.ts @@ -0,0 +1,685 @@ +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 { type Route, getTransformedRoutes, normalizeRoutes } from '@vercel/routing-utils'; +import type { + AstroAdapter, + AstroConfig, + AstroIntegration, + AstroIntegrationLogger, + HookParameters, + IntegrationResolvedRoute, +} from 'astro'; +import { AstroError } from 'astro/errors'; +import glob from 'fast-glob'; +import { + type DevImageService, + type VercelImageConfig, + getAstroImageConfig, + getDefaultImageConfig, +} from './image/shared.js'; +import type { RemotePattern } from './image/shared.js'; +import { copyDependenciesToFunction } from './lib/nft.js'; +import { escapeRegex, getRedirects } from './lib/redirects.js'; +import { + type VercelWebAnalyticsConfig, + getInjectableWebAnalyticsContent, +} from './lib/web-analytics.js'; +import { generateEdgeMiddleware } from './serverless/middleware.js'; + +const PACKAGE_NAME = '@astrojs/vercel'; + +/** + * The edge function calls the node server at /_render, + * with the original path as the value of this header. + */ +export const ASTRO_PATH_HEADER = 'x-astro-path'; +export const ASTRO_PATH_PARAM = 'x_astro_path'; + +/** + * The edge function calls the node server at /_render, + * with the locals serialized into this header. + */ +export const ASTRO_LOCALS_HEADER = 'x-astro-locals'; +export const ASTRO_MIDDLEWARE_SECRET_HEADER = 'x-astro-middleware-secret'; +export const VERCEL_EDGE_MIDDLEWARE_FILE = 'vercel-edge-middleware'; + +// Vercel routes the folder names to a path on the deployed website. +// We attempt to avoid interfering by prefixing with an underscore. +export const NODE_PATH = '_render'; +const MIDDLEWARE_PATH = '_middleware'; + +// This isn't documented by vercel anywhere, but unlike serverless +// and edge functions, isr functions are not passed the original path. +// Instead, we have to use $0 to refer to the regex match from "src". +const ISR_PATH = `/_isr?${ASTRO_PATH_PARAM}=$0`; + +// https://vercel.com/docs/concepts/functions/serverless-functions/runtimes/node-js#node.js-version +const SUPPORTED_NODE_VERSIONS: Record< + string, + | { status: 'default' } + | { status: 'available' } + | { status: 'beta' } + | { status: 'retiring'; removal: Date | string; warnDate: Date } + | { status: 'deprecated'; removal: Date } +> = { + 18: { status: 'retiring', removal: 'Early 2025', warnDate: new Date('October 1 2024') }, + 20: { status: 'available' }, + 22: { status: 'default' }, +}; + +function getAdapter({ + edgeMiddleware, + middlewareSecret, + skewProtection, + buildOutput, +}: { + buildOutput: 'server' | 'static'; + edgeMiddleware: boolean; + middlewareSecret: string; + skewProtection: boolean; +}): AstroAdapter { + return { + name: PACKAGE_NAME, + serverEntrypoint: `${PACKAGE_NAME}/entrypoint`, + exports: ['default'], + args: { middlewareSecret, skewProtection }, + adapterFeatures: { + edgeMiddleware, + buildOutput, + }, + supportedAstroFeatures: { + hybridOutput: 'stable', + staticOutput: 'stable', + serverOutput: 'stable', + sharpImageService: 'stable', + i18nDomains: 'experimental', + envGetSecret: 'stable', + }, + }; +} + +export interface VercelServerlessConfig { + /** Configuration for [Vercel Web Analytics](https://vercel.com/docs/concepts/analytics). */ + webAnalytics?: VercelWebAnalyticsConfig; + + /** Force files to be bundled with your function. This is helpful when you notice missing files. */ + includeFiles?: string[]; + + /** Exclude any files from the bundling process that would otherwise be included. */ + excludeFiles?: string[]; + + /** When enabled, an Image Service powered by the Vercel Image Optimization API will be automatically configured and used in production. In development, the image service specified by devImageService will be used instead. */ + imageService?: boolean; + + /** Configuration options for [Vercel’s Image Optimization API](https://vercel.com/docs/concepts/image-optimization). See [Vercel’s image configuration documentation](https://vercel.com/docs/build-output-api/v3/configuration#images) for a complete list of supported parameters. */ + imagesConfig?: VercelImageConfig; + + /** Allows you to configure which image service to use in development when imageService is enabled. */ + devImageService?: DevImageService; + + /** Whether to create the Vercel Edge middleware from an Astro middleware in your code base. */ + edgeMiddleware?: boolean; + + /** The maximum duration (in seconds) that Serverless Functions can run before timing out. See the [Vercel documentation](https://vercel.com/docs/functions/serverless-functions/runtimes#maxduration) for the default and maximum limit for your account plan. */ + maxDuration?: number; + + /** Whether to cache on-demand rendered pages in the same way as static files. */ + isr?: boolean | VercelISRConfig; + /** + * It enables Vercel skew protection: https://vercel.com/docs/deployments/skew-protection + */ + skewProtection?: boolean; +} + +interface VercelISRConfig { + /** + * A secret random string that you create. + * Its presence in the `__prerender_bypass` cookie will result in fresh responses being served, bypassing the cache. See Vercel’s documentation on [Draft Mode](https://vercel.com/docs/build-output-api/v3/features#draft-mode) for more information. + * Its presence in the `x-prerender-revalidate` header will result in a fresh response which will then be cached for all future requests to be used. See Vercel’s documentation on [On-Demand Incremental Static Regeneration (ISR)](https://vercel.com/docs/build-output-api/v3/features#on-demand-incremental-static-regeneration-isr) for more information. + * + * @default `undefined` + */ + bypassToken?: string; + + /** + * Expiration time (in seconds) before the pages will be re-generated. + * + * Setting to `false` means that the page will stay cached as long as the current deployment is in production. + * + * @default `false` + */ + expiration?: number | false; + + /** + * Paths that will always be served by a serverless function instead of an ISR function. + * + * @default `[]` + */ + exclude?: string[]; +} + +export default function vercelAdapter({ + webAnalytics, + includeFiles: _includeFiles = [], + excludeFiles: _excludeFiles = [], + imageService, + imagesConfig, + devImageService = 'sharp', + edgeMiddleware = false, + maxDuration, + isr = false, + skewProtection = false, +}: VercelServerlessConfig = {}): AstroIntegration { + 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: AstroConfig; + let _buildTempFolder: URL; + let _serverEntry: string; + let _entryPoints: Map<Pick<IntegrationResolvedRoute, 'entrypoint' | 'patternRegex'>, URL>; + let _middlewareEntryPoint: URL | undefined; + // Extra files to be merged with `includeFiles` during build + const extraFilesToInclude: URL[] = []; + // Secret used to verify that the caller is the astro-generated edge middleware and not a third-party + const middlewareSecret = crypto.randomUUID(); + + let _buildOutput: 'server' | 'static'; + + let staticDir: URL | undefined; + let routes: IntegrationResolvedRoute[]; + + 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, + }) + ); + } else { + setAdapter( + getAdapter({ + edgeMiddleware: false, + middlewareSecret: '', + skewProtection, + buildOutput: _buildOutput, + }) + ); + } + _config = config; + _buildTempFolder = config.build.server; + _serverEntry = config.build.serverEntry; + }, + 'astro:build:start': async () => { + // Ensure to have `.vercel/output` empty. + 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:done': async ({ logger }: HookParameters<'astro:build:done'>) => { + 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: Array<{ + src: string; + dest: string; + middlewarePath?: string; + }> = []; + + if (_buildOutput === 'server') { + // Merge any includes from `vite.assetsInclude + if (_config.vite.assetsInclude) { + const mergeGlobbedIncludes = (globPattern: unknown) => { + if (typeof globPattern === 'string') { + const entries = glob.sync(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 + ); + + // Multiple entrypoint support + if (_entryPoints.size) { + const getRouteFuncName = (route: Pick<IntegrationResolvedRoute, 'entrypoint'>) => + route.entrypoint.replace('src/pages/', ''); + + const getFallbackFuncName = (entryFile: URL) => + 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 dest = _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH; + for (const route of isrConfig.exclude) { + // vercel interprets src as a regex pattern, so we need to escape it + routeDefinitions.push({ src: escapeRegex(route), dest }); + } + } + await builder.buildISRFolder(entryFile, '_isr', isrConfig, _config.root); + for (const route of routes) { + 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 destination = new URL('./.vercel/output/config.json', _config.root); + const finalRoutes: Route[] = [ + { + 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, + }); + } + } + // The Vercel `trailingSlash` option + let trailingSlash: boolean | undefined; + // Vercel's `trailingSlash` option maps to Astro's like so: + // - `true` -> `"always"` + // - `false` -> `"never"` + // - `undefined` -> `"ignore"` + // If config is set to "ignore", we leave it as undefined. + if (_config.trailingSlash && _config.trailingSlash !== 'ignore') { + // Otherwise, map it accordingly. + 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}` : undefined + ); + } + + let images: VercelImageConfig | undefined; + if (imageService || imagesConfig) { + if (imagesConfig) { + images = { + ...imagesConfig, + domains: [...imagesConfig.domains, ..._config.image.domains], + remotePatterns: [...(imagesConfig.remotePatterns ?? [])], + }; + const remotePatterns = _config.image.remotePatterns; + for (const pattern of remotePatterns) { + if (isAcceptedPattern(pattern)) { + images.remotePatterns?.push(pattern); + } + } + } else { + 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}` + : undefined + ); + } + + // Output configuration + // https://vercel.com/docs/build-output-api/v3#build-output-configuration + await writeJson(destination, { + version: 3, + routes: normalized.routes, + images, + }); + + // Remove temporary folder + if (_buildOutput === 'server') { + await removeDir(_buildTempFolder); + } + }, + }, + }; +} + +function isAcceptedPattern(pattern: any): pattern is RemotePattern { + if (pattern == null) { + return false; + } + if (!pattern.hostname) { + return false; + } + if (pattern.protocol && (pattern.protocol !== 'http' || pattern.protocol !== 'https')) { + return false; + } + return true; +} + +type Runtime = `nodejs${string}.x`; + +class VercelBuilder { + readonly NTF_CACHE = {}; + + constructor( + readonly config: AstroConfig, + readonly excludeFiles: URL[], + readonly includeFiles: URL[], + readonly logger: AstroIntegrationLogger, + readonly outDir: URL, + readonly maxDuration?: number, + readonly runtime = getRuntime(process, logger) + ) {} + + async buildServerlessFolder(entry: URL, functionName: string, root: URL) { + const { includeFiles, excludeFiles, logger, NTF_CACHE, runtime, maxDuration } = this; + // .vercel/output/functions/<name>.func/ + 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); + + // Copy necessary files (e.g. node_modules/) + const { handler } = await copyDependenciesToFunction( + { + entry, + outDir: functionFolder, + includeFiles, + excludeFiles, + logger, + root, + }, + NTF_CACHE + ); + + // Enable ESM + // https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda/ + await writeJson(packageJson, { type: 'module' }); + + // Serverless function config + // https://vercel.com/docs/build-output-api/v3#vercel-primitives/serverless-functions/configuration + await writeJson(vcConfig, { + runtime, + handler: handler.replaceAll('\\', '/'), + launcherType: 'Nodejs', + maxDuration, + supportsResponseStreaming: true, + }); + } + + async buildISRFolder(entry: URL, functionName: string, isr: VercelISRConfig, root: URL) { + await this.buildServerlessFolder(entry, functionName, root); + const prerenderConfig = new URL( + `./functions/${functionName}.prerender-config.json`, + this.outDir + ); + // https://vercel.com/docs/build-output-api/v3/primitives#prerender-configuration-file + await writeJson(prerenderConfig, { + expiration: isr.expiration ?? false, + bypassToken: isr.bypassToken, + allowQuery: [ASTRO_PATH_PARAM], + passQuery: true, + }); + } + + async buildMiddlewareFolder(entry: URL, functionName: string, middlewareSecret: string) { + 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(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) { + 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` + ); + return 'nodejs18.x'; + } + if (support.status === 'default' || support.status === 'available') { + return `nodejs${major}.x`; + } + if (support.status === 'retiring') { + if (support.warnDate && 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(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`; + } + return 'nodejs18.x'; +} diff --git a/packages/integrations/vercel/src/lib/nft.ts b/packages/integrations/vercel/src/lib/nft.ts new file mode 100644 index 000000000..d72979e89 --- /dev/null +++ b/packages/integrations/vercel/src/lib/nft.ts @@ -0,0 +1,83 @@ +import { relative as relativePath } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { copyFilesToFolder } from '@astrojs/internal-helpers/fs'; +import { appendForwardSlash } from '@astrojs/internal-helpers/path'; +import type { AstroIntegrationLogger } from 'astro'; +import { searchForWorkspaceRoot } from './searchRoot.js'; + +export async function copyDependenciesToFunction( + { + entry, + outDir, + includeFiles, + excludeFiles, + logger, + root, + }: { + entry: URL; + outDir: URL; + includeFiles: URL[]; + excludeFiles: URL[]; + logger: AstroIntegrationLogger; + root: URL; + }, + // we want to pass the caching by reference, and not by value + cache: object +): Promise<{ handler: string }> { + const entryPath = fileURLToPath(entry); + logger.info(`Bundling function ${relativePath(fileURLToPath(outDir), entryPath)}`); + + // Set the base to the workspace root + const base = pathToFileURL(appendForwardSlash(searchForWorkspaceRoot(fileURLToPath(root)))); + + // The Vite bundle includes an import to `@vercel/nft` for some reason, + // and that trips up `@vercel/nft` itself during the adapter build. Using a + // dynamic import helps prevent the issue. + // TODO: investigate why + const { nodeFileTrace } = await import('@vercel/nft'); + const result = await nodeFileTrace([entryPath], { + base: fileURLToPath(base), + cache, + }); + + for (const error of result.warnings) { + if (error.message.startsWith('Failed to resolve dependency')) { + const [, module, file] = /Cannot find module '(.+?)' loaded from (.+)/.exec(error.message)!; + + // The import(astroRemark) sometimes fails to resolve, but it's not a problem + if (module === '@astrojs/') continue; + + // Sharp is always external and won't be able to be resolved, but that's also not a problem + if (module === 'sharp') continue; + + if (entryPath === file) { + logger.debug( + `[@astrojs/vercel] The module "${module}" couldn't be resolved. This may not be a problem, but it's worth checking.` + ); + } else { + logger.debug( + `[@astrojs/vercel] The module "${module}" inside the file "${file}" couldn't be resolved. This may not be a problem, but it's worth checking.` + ); + } + } + // parse errors are likely not js and can safely be ignored, + // such as this html file in "main" meant for nw instead of node: + // https://github.com/vercel/nft/issues/311 + else if (error.message.startsWith('Failed to parse')) { + continue; + } else { + throw error; + } + } + + const commonAncestor = await copyFilesToFolder( + [...result.fileList].map((file) => new URL(file, base)).concat(includeFiles), + outDir, + excludeFiles + ); + + return { + // serverEntry location inside the outDir + handler: relativePath(commonAncestor, entryPath), + }; +} diff --git a/packages/integrations/vercel/src/lib/redirects.ts b/packages/integrations/vercel/src/lib/redirects.ts new file mode 100644 index 000000000..23a4dc11f --- /dev/null +++ b/packages/integrations/vercel/src/lib/redirects.ts @@ -0,0 +1,131 @@ +import nodePath from 'node:path'; +import { removeLeadingForwardSlash } from '@astrojs/internal-helpers/path'; +import type { AstroConfig, IntegrationResolvedRoute, RoutePart } from 'astro'; + +import type { Redirect } from '@vercel/routing-utils'; + +const pathJoin = nodePath.posix.join; + +// Copied from astro/packages/astro/src/core/routing/manifest/create.ts +// Disable eslint as we're not sure how to improve this regex yet +// eslint-disable-next-line regexp/no-super-linear-backtracking +const ROUTE_DYNAMIC_SPLIT = /\[(.+?\(.+?\)|.+?)\]/; +const ROUTE_SPREAD = /^\.{3}.+$/; +function getParts(part: string, file: string) { + const result: RoutePart[] = []; + part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => { + if (!str) return; + const dynamic = i % 2 === 1; + + const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str]; + + if (!content || (dynamic && !/^(?:\.\.\.)?[\w$]+$/.test(content))) { + throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`); + } + + result.push({ + content, + dynamic, + spread: dynamic && ROUTE_SPREAD.test(content), + }); + }); + + return result; +} +/** + * Convert Astro routes into Vercel path-to-regexp syntax, which are the input for getTransformedRoutes + */ +function getMatchPattern(segments: RoutePart[][]) { + return segments + .map((segment) => { + return segment + .map((part) => { + if (part.spread) { + // Extract parameter name from spread syntax (e.g., "...slug" -> "slug") + const paramName = part.content.startsWith('...') ? part.content.slice(3) : part.content; + return `:${paramName}*`; + } + if (part.dynamic) { + return `:${part.content}`; + } + return part.content; + }) + .join(''); + }) + .join('/'); +} + +// Copied from /home/juanm04/dev/misc/astro/packages/astro/src/core/routing/manifest/create.ts +// 2022-04-26 +function getMatchRegex(segments: RoutePart[][]) { + return segments + .map((segment, segmentIndex) => { + return segment.length === 1 && segment[0].spread + ? '(?:\\/(.*?))?' + : // Omit leading slash if segment is a spread. + // This is handled using a regex in Astro core. + // To avoid complex data massaging, we handle in-place here. + (segmentIndex === 0 ? '' : '/') + + segment + .map((part) => { + if (part) + return part.spread + ? '(.*?)' + : part.dynamic + ? '([^/]+?)' + : part.content + .normalize() + .replace(/\?/g, '%3F') + .replace(/#/g, '%23') + .replace(/%5B/g, '[') + .replace(/%5D/g, ']') + .replace(/[*+?^${}()|[\]\\]/g, '\\$&'); + }) + .join(''); + }) + .join(''); +} + +function getRedirectLocation(route: IntegrationResolvedRoute, config: AstroConfig): string { + if (route.redirectRoute) { + const pattern = getMatchPattern(route.redirectRoute.segments); + return pathJoin(config.base, pattern); + } + + if (typeof route.redirect === 'object') { + return pathJoin(config.base, route.redirect.destination); + } + return pathJoin(config.base, route.redirect || ''); +} + +function getRedirectStatus(route: IntegrationResolvedRoute): number { + if (typeof route.redirect === 'object') { + return route.redirect.status; + } + return 301; +} + +export function escapeRegex(content: string) { + const segments = removeLeadingForwardSlash(content) + .split(nodePath.posix.sep) + .filter(Boolean) + .map((s: string) => { + return getParts(s, content); + }); + return `^/${getMatchRegex(segments)}$`; +} + +export function getRedirects(routes: IntegrationResolvedRoute[], config: AstroConfig): Redirect[] { + const redirects: Redirect[] = []; + + for (const route of routes) { + if (route.type === 'redirect') { + redirects.push({ + source: config.base + getMatchPattern(route.segments), + destination: getRedirectLocation(route, config), + statusCode: getRedirectStatus(route), + }); + } + } + return redirects; +} diff --git a/packages/integrations/vercel/src/lib/searchRoot.ts b/packages/integrations/vercel/src/lib/searchRoot.ts new file mode 100644 index 000000000..832f6e498 --- /dev/null +++ b/packages/integrations/vercel/src/lib/searchRoot.ts @@ -0,0 +1,101 @@ +// Taken from: https://github.com/vitejs/vite/blob/1a76300cd16827f0640924fdc21747ce140c35fb/packages/vite/src/node/server/searchRoot.ts +// MIT license +// See https://github.com/vitejs/vite/blob/1a76300cd16827f0640924fdc21747ce140c35fb/LICENSE +import fs from 'node:fs'; +import { dirname, join } from 'node:path'; + +// https://github.com/vitejs/vite/issues/2820#issuecomment-812495079 +const ROOT_FILES = [ + // '.git', + + // https://pnpm.io/workspaces/ + 'pnpm-workspace.yaml', + + // https://rushjs.io/pages/advanced/config_files/ + // 'rush.json', + + // https://nx.dev/latest/react/getting-started/nx-setup + // 'workspace.json', + // 'nx.json', + + // https://github.com/lerna/lerna#lernajson + 'lerna.json', +]; + +export function tryStatSync(file: string): fs.Stats | undefined { + try { + // The "throwIfNoEntry" is a performance optimization for cases where the file does not exist + return fs.statSync(file, { throwIfNoEntry: false }); + } catch { + // Ignore errors + } +} + +export function isFileReadable(filename: string): boolean { + if (!tryStatSync(filename)) { + return false; + } + + try { + // Check if current process has read permission to the file + fs.accessSync(filename, fs.constants.R_OK); + + return true; + } catch { + return false; + } +} + +// npm: https://docs.npmjs.com/cli/v7/using-npm/workspaces#installing-workspaces +// yarn: https://classic.yarnpkg.com/en/docs/workspaces/#toc-how-to-use-it +function hasWorkspacePackageJSON(root: string): boolean { + const path = join(root, 'package.json'); + if (!isFileReadable(path)) { + return false; + } + try { + const content = JSON.parse(fs.readFileSync(path, 'utf-8')) || {}; + return !!content.workspaces; + } catch { + return false; + } +} + +function hasRootFile(root: string): boolean { + return ROOT_FILES.some((file) => fs.existsSync(join(root, file))); +} + +function hasPackageJSON(root: string) { + const path = join(root, 'package.json'); + return fs.existsSync(path); +} + +/** + * Search up for the nearest `package.json` + */ +export function searchForPackageRoot(current: string, root = current): string { + if (hasPackageJSON(current)) return current; + + const dir = dirname(current); + // reach the fs root + if (!dir || dir === current) return root; + + return searchForPackageRoot(dir, root); +} + +/** + * Search up for the nearest workspace root + */ +export function searchForWorkspaceRoot( + current: string, + root = searchForPackageRoot(current) +): string { + if (hasRootFile(current)) return current; + if (hasWorkspacePackageJSON(current)) return current; + + const dir = dirname(current); + // reach the fs root + if (!dir || dir === current) return root; + + return searchForWorkspaceRoot(dir, root); +} diff --git a/packages/integrations/vercel/src/lib/web-analytics.ts b/packages/integrations/vercel/src/lib/web-analytics.ts new file mode 100644 index 000000000..d6ee4d78d --- /dev/null +++ b/packages/integrations/vercel/src/lib/web-analytics.ts @@ -0,0 +1,30 @@ +export type VercelWebAnalyticsConfig = { + enabled: boolean; +}; + +export async function getInjectableWebAnalyticsContent({ + mode, +}: { + mode: 'development' | 'production'; +}) { + const base = `window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };`; + + if (mode === 'development') { + return ` + ${base} + var script = document.createElement('script'); + script.defer = true; + script.src = 'https://cdn.vercel-insights.com/v1/script.debug.js'; + var head = document.querySelector('head'); + head.appendChild(script); + `; + } + + return `${base} + var script = document.createElement('script'); + script.defer = true; + script.src = '/_vercel/insights/script.js'; + var head = document.querySelector('head'); + head.appendChild(script); + `; +} diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts new file mode 100644 index 000000000..86464f89b --- /dev/null +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -0,0 +1,10 @@ +import type { AstroIntegration } from 'astro'; +import type { VercelServerlessConfig } from '../index.js'; +import vercelIntegration from '../index.js'; + +export default function serverless(config: VercelServerlessConfig): AstroIntegration { + console.warn( + 'The "@astrojs/vercel/serverless" import is deprecated and will be removed in a future release. Please import from "@astrojs/vercel" instead.' + ); + return vercelIntegration(config); +} diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts new file mode 100644 index 000000000..f1f1f256c --- /dev/null +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -0,0 +1,58 @@ +// Keep at the top +import './polyfill.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { SSRManifest } from 'astro'; +import { NodeApp } from 'astro/app/node'; +import { setGetEnv } from 'astro/env/setup'; +import { + ASTRO_LOCALS_HEADER, + ASTRO_MIDDLEWARE_SECRET_HEADER, + ASTRO_PATH_HEADER, + ASTRO_PATH_PARAM, +} from '../index.js'; + +setGetEnv((key) => process.env[key]); + +export const createExports = ( + manifest: SSRManifest, + { middlewareSecret, skewProtection }: { middlewareSecret: string; skewProtection: boolean } +) => { + const app = new NodeApp(manifest); + const handler = async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(`https://example.com${req.url}`); + const clientAddress = req.headers['x-forwarded-for'] as string | undefined; + const localsHeader = req.headers[ASTRO_LOCALS_HEADER]; + const middlewareSecretHeader = req.headers[ASTRO_MIDDLEWARE_SECRET_HEADER]; + const realPath = req.headers[ASTRO_PATH_HEADER] ?? url.searchParams.get(ASTRO_PATH_PARAM); + if (typeof realPath === 'string') { + req.url = realPath; + } + + let locals = {}; + if (localsHeader) { + if (middlewareSecretHeader !== middlewareSecret) { + res.statusCode = 403; + res.end('Forbidden'); + return; + } + locals = + typeof localsHeader === 'string' ? JSON.parse(localsHeader) : JSON.parse(localsHeader[0]); + } + // hide the secret from the rest of user code + delete req.headers[ASTRO_MIDDLEWARE_SECRET_HEADER]; + + // https://vercel.com/docs/deployments/skew-protection#supported-frameworks + if (skewProtection && process.env.VERCEL_SKEW_PROTECTION_ENABLED === '1') { + req.headers['x-deployment-id'] = process.env.VERCEL_DEPLOYMENT_ID; + } + + const webResponse = await app.render(req, { addCookieHeader: true, clientAddress, locals }); + await NodeApp.writeResponse(webResponse, res); + }; + + return { default: handler }; +}; + +// HACK: prevent warning +// @astrojs-ssr-virtual-entry (22:23) "start" is not exported by "dist/serverless/entrypoint.js", imported by "@astrojs-ssr-virtual-entry". +export function start() {} diff --git a/packages/integrations/vercel/src/serverless/middleware.ts b/packages/integrations/vercel/src/serverless/middleware.ts new file mode 100644 index 000000000..3980186f5 --- /dev/null +++ b/packages/integrations/vercel/src/serverless/middleware.ts @@ -0,0 +1,132 @@ +import { existsSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { AstroIntegrationLogger } from 'astro'; +import { + ASTRO_LOCALS_HEADER, + ASTRO_MIDDLEWARE_SECRET_HEADER, + ASTRO_PATH_HEADER, + NODE_PATH, +} from '../index.js'; + +/** + * It generates the Vercel Edge Middleware file. + * + * It creates a temporary file, the edge middleware, with some dynamic info. + * + * Then this file gets bundled with esbuild. The bundle phase will inline the Astro middleware code. + * + * @param astroMiddlewareEntryPointPath + * @param root + * @param vercelEdgeMiddlewareHandlerPath + * @param outPath + * @param middlewareSecret + * @param logger + * @returns {Promise<URL>} The path to the bundled file + */ +export async function generateEdgeMiddleware( + astroMiddlewareEntryPointPath: URL, + root: URL, + vercelEdgeMiddlewareHandlerPath: URL, + outPath: URL, + middlewareSecret: string, + logger: AstroIntegrationLogger +): Promise<URL> { + const code = edgeMiddlewareTemplate( + astroMiddlewareEntryPointPath, + vercelEdgeMiddlewareHandlerPath, + middlewareSecret, + logger + ); + // https://vercel.com/docs/concepts/functions/edge-middleware#create-edge-middleware + const bundledFilePath = fileURLToPath(outPath); + const esbuild = await import('esbuild'); + await esbuild.build({ + stdin: { + contents: code, + resolveDir: fileURLToPath(root), + }, + // Vercel Edge runtime targets ESNext, because Cloudflare Workers update v8 weekly + // https://github.com/vercel/vercel/blob/1006f2ae9d67ea4b3cbb1073e79d14d063d42436/packages/next/scripts/build-edge-function-template.js + target: 'esnext', + platform: 'browser', + // esbuild automatically adds the browser, import and default conditions + // https://esbuild.github.io/api/#conditions + // https://runtime-keys.proposal.wintercg.org/#edge-light + conditions: ['edge-light', 'workerd', 'worker'], + outfile: bundledFilePath, + allowOverwrite: true, + format: 'esm', + bundle: true, + minify: false, + // ensure node built-in modules are namespaced with `node:` + plugins: [ + { + name: 'esbuild-namespace-node-built-in-modules', + setup(build) { + const filter = new RegExp(builtinModules.map((mod) => `(^${mod}$)`).join('|')); + build.onResolve({ filter }, (args) => ({ path: 'node:' + args.path, external: true })); + }, + }, + ], + }); + return pathToFileURL(bundledFilePath); +} + +function edgeMiddlewareTemplate( + astroMiddlewareEntryPointPath: URL, + vercelEdgeMiddlewareHandlerPath: URL, + middlewareSecret: string, + logger: AstroIntegrationLogger +) { + const middlewarePath = JSON.stringify( + fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/') + ); + const filePathEdgeMiddleware = fileURLToPath(vercelEdgeMiddlewareHandlerPath); + let handlerTemplateImport = ''; + let handlerTemplateCall = '{}'; + if (existsSync(filePathEdgeMiddleware + '.js') || existsSync(filePathEdgeMiddleware + '.ts')) { + logger.warn( + 'Usage of `vercel-edge-middleware.js` is deprecated. You can now use the `waitUntil(promise)` function directly as `ctx.locals.waitUntil(promise)`.' + ); + const stringified = JSON.stringify(filePathEdgeMiddleware.replace(/\\/g, '/')); + handlerTemplateImport = `import handler from ${stringified}`; + handlerTemplateCall = `await handler({ request, context })`; + } else { + } + return ` + ${handlerTemplateImport} +import { onRequest } from ${middlewarePath}; +import { createContext, trySerializeLocals } from 'astro/middleware'; +export default async function middleware(request, context) { + const ctx = createContext({ + request, + params: {} + }); + Object.assign(ctx.locals, { vercel: { edge: context }, ...${handlerTemplateCall} }); + const { origin } = new URL(request.url); + const next = async () => { + const { vercel, ...locals } = ctx.locals; + const response = await fetch(new URL('/${NODE_PATH}', request.url), { + headers: { + ...Object.fromEntries(request.headers.entries()), + '${ASTRO_MIDDLEWARE_SECRET_HEADER}': '${middlewareSecret}', + '${ASTRO_PATH_HEADER}': request.url.replace(origin, ''), + '${ASTRO_LOCALS_HEADER}': trySerializeLocals(locals) + } + }); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }; + + const response = await onRequest(ctx, next); + // Append cookies from Astro.cookies + for(const setCookieHeaderValue of ctx.cookies.headers()) { + response.headers.append('set-cookie', setCookieHeaderValue); + } + return response; +}`; +} diff --git a/packages/integrations/vercel/src/serverless/polyfill.ts b/packages/integrations/vercel/src/serverless/polyfill.ts new file mode 100644 index 000000000..dc00f45d7 --- /dev/null +++ b/packages/integrations/vercel/src/serverless/polyfill.ts @@ -0,0 +1,3 @@ +import { applyPolyfills } from 'astro/app/node'; + +applyPolyfills(); diff --git a/packages/integrations/vercel/src/static/adapter.ts b/packages/integrations/vercel/src/static/adapter.ts new file mode 100644 index 000000000..348994902 --- /dev/null +++ b/packages/integrations/vercel/src/static/adapter.ts @@ -0,0 +1,10 @@ +import type { AstroIntegration } from 'astro'; +import type { VercelServerlessConfig } from '../index.js'; +import vercelIntegration from '../index.js'; + +export default function staticAdapter(config: VercelServerlessConfig): AstroIntegration { + console.warn( + 'The "@astrojs/vercel/static" import is deprecated and will be removed in a future release. Please import from "@astrojs/vercel" instead.' + ); + return vercelIntegration(config); +} diff --git a/packages/integrations/vercel/src/types.d.ts b/packages/integrations/vercel/src/types.d.ts new file mode 100644 index 000000000..1c5b8d2db --- /dev/null +++ b/packages/integrations/vercel/src/types.d.ts @@ -0,0 +1,3 @@ +import type { AnalyticsProps } from '@vercel/analytics'; + +export type VercelWebAnalyticsBeforeSend = AnalyticsProps['beforeSend']; |