Skip to content

Commit

Permalink
Merge pull request #13147 from withastro/move-vercel
Browse files Browse the repository at this point in the history
chore: move Vercel adapter to core monorepo
  • Loading branch information
ematipico authored Feb 7, 2025
2 parents 4e7d97f + 64b118a commit efef413
Show file tree
Hide file tree
Showing 123 changed files with 4,906 additions and 7 deletions.
1,629 changes: 1,628 additions & 1 deletion packages/integrations/vercel/CHANGELOG.md

Large diffs are not rendered by default.

37 changes: 36 additions & 1 deletion packages/integrations/vercel/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
# @astrojs/vercel

The Vercel adapter package has moved. Please see [the new repository for the Vercel adapter](https://github.com/withastro/adapters/tree/main/packages/vercel).
This adapter allows Astro to deploy your SSR site to [Vercel](https://www.vercel.com/).

## Documentation

Read the [`@astrojs/vercel` docs][docs]

## Support

- Get help in the [Astro Discord][discord]. Post questions in our `#support` forum, or visit our dedicated `#dev` channel to discuss current development and more!

- Check our [Astro Integration Documentation][astro-integration] for more on integrations.

- Submit bug reports and feature requests as [GitHub issues][issues].

## Contributing

This package is maintained by Astro's Core team. You're welcome to submit an issue or PR! These links will help you get started:

- [Contributor Manual][contributing]
- [Code of Conduct][coc]
- [Community Guide][community]

## License

MIT

Copyright (c) 2023–present [Astro][astro]

[astro]: https://astro.build/
[docs]: https://docs.astro.build/en/guides/integrations-guide/vercel/
[contributing]: https://github.com/withastro/astro/blob/main/CONTRIBUTING.md
[coc]: https://github.com/withastro/.github/blob/main/CODE_OF_CONDUCT.md
[community]: https://github.com/withastro/.github/blob/main/COMMUNITY_GUIDE.md
[discord]: https://astro.build/chat/
[issues]: https://github.com/withastro/astro/issues
[astro-integration]: https://docs.astro.build/en/guides/integrations-guide/
58 changes: 54 additions & 4 deletions packages/integrations/vercel/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,58 @@
{
"name": "@astrojs/vercel",
"version": "1.0.0",
"private": true,
"description": "Deploy your site to Vercel",
"version": "8.0.6",
"type": "module",
"keywords": [],
"dont_remove": "This is a placeholder for the sake of the docs smoke test"
"author": "withastro",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/withastro/adapters.git",
"directory": "packages/vercel"
},
"keywords": ["withastro", "astro-adapter"],
"bugs": "https://github.com/withastro/adapters/issues",
"homepage": "https://docs.astro.build/en/guides/integrations-guide/vercel/",
"exports": {
".": "./dist/index.js",
"./entrypoint": "./dist/serverless/entrypoint.js",
"./serverless": "./dist/serverless/adapter.js",
"./serverless/entrypoint": "./dist/serverless/entrypoint.js",
"./static": "./dist/static/adapter.js",
"./build-image-service": "./dist/image/build-service.js",
"./dev-image-service": "./dist/image/dev-service.js",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"serverless": ["dist/serverless/adapter.d.ts"],
"static": ["dist/static/adapter.d.ts"]
}
},
"files": ["dist", "types.d.ts"],
"scripts": {
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
"test": "astro-scripts test --timeout 50000 \"test/**/!(hosted).test.js\"",
"test:hosted": "astro-scripts test --timeout 30000 \"test/hosted/*.test.js\""
},
"dependencies": {
"@astrojs/internal-helpers": "^0.5.1",
"@vercel/analytics": "^1.4.1",
"@vercel/edge": "^1.2.1",
"@vercel/nft": "^0.29.0",
"@vercel/routing-utils": "5.0.2",
"esbuild": "^0.24.0",
"fast-glob": "^3.3.3"
},
"peerDependencies": {
"astro": "^5.0.0"
},
"devDependencies": {
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"cheerio": "1.0.0"
},
"publishConfig": {
"provenance": true
}
}
64 changes: 64 additions & 0 deletions packages/integrations/vercel/src/image/build-service.ts
Original file line number Diff line number Diff line change
@@ -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;
31 changes: 31 additions & 0 deletions packages/integrations/vercel/src/image/dev-service.ts
Original file line number Diff line number Diff line change
@@ -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;
35 changes: 35 additions & 0 deletions packages/integrations/vercel/src/image/shared-dev-service.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
163 changes: 163 additions & 0 deletions packages/integrations/vercel/src/image/shared.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit efef413

Please sign in to comment.