feat(vercel): Add support for image optimization API (#6845)

* feat(vercel): Add support for image optimization API

* chore: changeset

* feat: implement image service

* feat: dev service

* feat: full local service

* fix: move assets check to astro:config:done

* feat: update with new settings

* fix: remove unused param

* test: add tsets

* fix: rename to imageService

* docs: add docs

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* docs(vercel): Add Added In mentions

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Erika 2023-05-02 09:42:48 +02:00 committed by GitHub
parent 980246f148
commit 6063f56573
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 484 additions and 8 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/vercel': minor
---
Add support for using the Vercel Image Optimization API through `astro:assets`

View file

@ -14,7 +14,7 @@ Learn how to deploy your Astro site in our [Vercel deployment guide](https://doc
## Why Astro Vercel
If you're using Astro as a static site builder — its behavior out of the box — you don't need an adapter.
If you're using Astro as a static site builder — its behavior out of the box — you don't need an adapter.
If you wish to [use server-side rendering (SSR)](https://docs.astro.build/en/guides/server-side-rendering/), Astro requires an adapter that matches your deployment runtime.
@ -108,6 +108,63 @@ export default defineConfig({
});
```
### imageConfig
**Type:** `VercelImageConfig`<br>
**Available for:** Edge, Serverless, Static
**Added in:** `@astrojs/vercel@3.3.0`
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.
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/static';
export default defineConfig({
output: 'server',
adapter: vercel({
imageConfig: {
sizes: [320, 640, 1280]
}
})
});
```
### imageService
**Type:** `boolean`<br>
**Available for:** Edge, Serverless, Static
**Added in:** `@astrojs/vercel@3.3.0`
When enabled, an [Image Service](https://docs.astro.build/en/reference/image-service-reference/) powered by the Vercel Image Optimization API will be automatically configured and used in production. In development, a built-in Squoosh-based service will be used instead.
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/static';
export default defineConfig({
output: 'server',
adapter: vercel({
imageService: true
})
});
```
```astro
---
import { Image } from "astro:assets";
import astroLogo from "../assets/logo.png";
---
<!-- This component -->
<Image src={astroLogo} alt="My super logo!" />
<!-- will become the following HTML -->
<img src="/_vercel/image?url=_astro/logo.hash.png&w=...&q=..." alt="My super logo!" loading="lazy" decoding="async" width="..." height="..." />
```
### includeFiles
**Type:** `string[]`<br>

View file

@ -23,6 +23,8 @@
"./serverless/entrypoint": "./dist/serverless/entrypoint.js",
"./static": "./dist/static/adapter.js",
"./analytics": "./dist/analytics.js",
"./build-image-service": "./dist/image/build-service.js",
"./dev-image-service": "./dist/image/dev-service.js",
"./package.json": "./package.json"
},
"typesVersions": {
@ -60,6 +62,7 @@
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"chai": "^4.3.6",
"mocha": "^9.2.2"
"mocha": "^9.2.2",
"cheerio": "^1.0.0-rc.11"
}
}

View file

@ -4,6 +4,12 @@ import esbuild from 'esbuild';
import { relative as relativePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
defaultImageConfig,
getImageConfig,
throwIfAssetsNotEnabled,
type VercelImageConfig,
} from '../image/shared.js';
import {
copyFilesToFunction,
getFilesFromFolder,
@ -26,11 +32,15 @@ function getAdapter(): AstroAdapter {
export interface VercelEdgeConfig {
includeFiles?: string[];
analytics?: boolean;
imageService?: boolean;
imagesConfig?: VercelImageConfig;
}
export default function vercelEdge({
includeFiles = [],
analytics,
imageService,
imagesConfig,
}: VercelEdgeConfig = {}): AstroIntegration {
let _config: AstroConfig;
let buildTempFolder: URL;
@ -52,9 +62,11 @@ export default function vercelEdge({
client: new URL('./static/', outDir),
server: new URL('./dist/', config.root),
},
...getImageConfig(imageService, imagesConfig, command),
});
},
'astro:config:done': ({ setAdapter, config }) => {
throwIfAssetsNotEnabled(config, imageService);
setAdapter(getAdapter());
_config = config;
buildTempFolder = config.build.server;
@ -64,7 +76,7 @@ export default function vercelEdge({
if (config.output === 'static') {
throw new Error(`
[@astrojs/vercel] \`output: "server"\` is required to use the edge adapter.
`);
}
},
@ -135,6 +147,9 @@ export default function vercelEdge({
{ handle: 'filesystem' },
{ src: '/.*', dest: 'render' },
],
...(imageService || imagesConfig
? { images: imagesConfig ? imagesConfig : defaultImageConfig }
: {}),
});
},
},

View file

@ -0,0 +1,60 @@
import type { ExternalImageService } from 'astro';
import { isESMImportedImage, sharedValidateOptions } from './shared';
const service: ExternalImageService = {
validateOptions: (options, serviceOptions) =>
sharedValidateOptions(options, serviceOptions, 'production'),
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;
}
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, ...attributes } = props;
return {
...attributes,
width: targetWidth,
height: targetHeight,
loading: attributes.loading ?? 'lazy',
decoding: attributes.decoding ?? 'async',
};
},
getURL(options, serviceOptions) {
const fileSrc =
typeof options.src === 'string' ? options.src : removeLeadingForwardSlash(options.src.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;

View file

@ -0,0 +1,57 @@
import type { LocalImageService } from 'astro';
// @ts-expect-error
import squooshService from 'astro/assets/services/squoosh';
import { sharedValidateOptions } from './shared';
const service: LocalImageService = {
validateOptions: (options, serviceOptions) =>
sharedValidateOptions(options, serviceOptions, 'development'),
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 squooshService.getHTMLAttributes(props, serviceOptions);
},
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') ? parseInt(params.get('w')!) : undefined,
quality: params.get('q'),
};
return transform;
},
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 = 'webp';
// The base Squoosh service works the same way as the Vercel Image Optimization API, so it's a safe fallback in local
return squooshService.transform(inputBuffer, transform, serviceOptions);
},
};
export default service;

View file

@ -0,0 +1,151 @@
import type { AstroConfig, ImageMetadata, ImageQualityPreset, ImageTransform } from 'astro';
export const defaultImageConfig: VercelImageConfig = {
sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
domains: [],
};
export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
return typeof src === 'object';
}
// https://vercel.com/docs/build-output-api/v3/configuration#images
type ImageFormat = 'image/avif' | 'image/webp';
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,
};
// TODO: Remove once Astro 3.0 is out and `experimental.assets` is no longer needed
export function throwIfAssetsNotEnabled(config: AstroConfig, imageService: boolean | undefined) {
if (!config.experimental.assets && imageService) {
throw new Error(
`Using the Vercel Image Optimization-powered image service requires \`experimental.assets\` to be enabled. See https://docs.astro.build/en/guides/assets/ for more information.`
);
}
}
export function getImageConfig(
images: boolean | undefined,
imagesConfig: VercelImageConfig | undefined,
command: string
) {
if (images) {
return {
image: {
service: {
entrypoint:
command === 'dev'
? '@astrojs/vercel/dev-image-service'
: '@astrojs/vercel/build-image-service',
config: imagesConfig ? imagesConfig : defaultImageConfig,
},
},
};
}
return {};
}
export function sharedValidateOptions(
options: ImageTransform,
serviceOptions: Record<string, any>,
mode: 'development' | 'production'
) {
const vercelImageOptions = serviceOptions 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;
}

View file

@ -2,6 +2,12 @@ import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro';
import glob from 'fast-glob';
import { pathToFileURL } from 'url';
import {
defaultImageConfig,
getImageConfig,
throwIfAssetsNotEnabled,
type VercelImageConfig,
} from '../image/shared.js';
import { getVercelOutput, removeDir, writeJson } from '../lib/fs.js';
import { copyDependenciesToFunction } from '../lib/nft.js';
import { getRedirects } from '../lib/redirects.js';
@ -20,12 +26,16 @@ export interface VercelServerlessConfig {
includeFiles?: string[];
excludeFiles?: string[];
analytics?: boolean;
imageService?: boolean;
imagesConfig?: VercelImageConfig;
}
export default function vercelServerless({
includeFiles,
excludeFiles,
analytics,
imageService,
imagesConfig,
}: VercelServerlessConfig = {}): AstroIntegration {
let _config: AstroConfig;
let buildTempFolder: URL;
@ -47,9 +57,11 @@ export default function vercelServerless({
client: new URL('./static/', outDir),
server: new URL('./dist/', config.root),
},
...getImageConfig(imageService, imagesConfig, command),
});
},
'astro:config:done': ({ setAdapter, config }) => {
throwIfAssetsNotEnabled(config, imageService);
setAdapter(getAdapter());
_config = config;
buildTempFolder = config.build.server;
@ -59,7 +71,7 @@ export default function vercelServerless({
if (config.output === 'static') {
throw new Error(`
[@astrojs/vercel] \`output: "server"\` is required to use the serverless adapter.
`);
}
},
@ -115,6 +127,9 @@ export default function vercelServerless({
{ handle: 'filesystem' },
{ src: '/.*', dest: 'render' },
],
...(imageService || imagesConfig
? { images: imagesConfig ? imagesConfig : defaultImageConfig }
: {}),
});
},
},

View file

@ -1,5 +1,11 @@
import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro';
import {
defaultImageConfig,
getImageConfig,
throwIfAssetsNotEnabled,
type VercelImageConfig,
} from '../image/shared.js';
import { emptyDir, getVercelOutput, writeJson } from '../lib/fs.js';
import { getRedirects } from '../lib/redirects.js';
@ -11,15 +17,21 @@ function getAdapter(): AstroAdapter {
export interface VercelStaticConfig {
analytics?: boolean;
imageService?: boolean;
imagesConfig?: VercelImageConfig;
}
export default function vercelStatic({ analytics }: VercelStaticConfig = {}): AstroIntegration {
export default function vercelStatic({
analytics,
imageService,
imagesConfig,
}: VercelStaticConfig = {}): AstroIntegration {
let _config: AstroConfig;
return {
name: '@astrojs/vercel',
hooks: {
'astro:config:setup': ({ command, config, updateConfig, injectScript }) => {
'astro:config:setup': ({ command, config, injectScript, updateConfig }) => {
if (command === 'build' && analytics) {
injectScript('page', 'import "@astrojs/vercel/analytics"');
}
@ -29,9 +41,11 @@ export default function vercelStatic({ analytics }: VercelStaticConfig = {}): As
build: {
format: 'directory',
},
...getImageConfig(imageService, imagesConfig, command),
});
},
'astro:config:done': ({ setAdapter, config }) => {
throwIfAssetsNotEnabled(config, imageService);
setAdapter(getAdapter());
_config = config;
@ -51,6 +65,9 @@ export default function vercelStatic({ analytics }: VercelStaticConfig = {}): As
await writeJson(new URL(`./config.json`, getVercelOutput(_config.root)), {
version: 3,
routes: [...getRedirects(routes, _config), { handle: 'filesystem' }],
...(imageService || imagesConfig
? { images: imagesConfig ? imagesConfig : defaultImageConfig }
: {}),
});
},
},

View file

@ -0,0 +1,9 @@
import vercel from '@astrojs/vercel/static';
import { defineConfig } from 'astro/config';
export default defineConfig({
adapter: vercel({imageService: true}),
experimental: {
assets: true
}
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/astro-vercel-image",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/vercel": "workspace:*",
"astro": "workspace:*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,6 @@
---
import { Image } from "astro:assets";
import astro from "../assets/astro.jpeg";
---
<Image src={astro} alt="Astro" />

View file

@ -0,0 +1,60 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('Image', () => {
/** @type {import('../../../astro/test/test-utils.js').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/image/',
});
await fixture.build();
});
it('build successful', async () => {
expect(await fixture.readFile('../.vercel/output/static/index.html')).to.be.ok;
});
it('has link to vercel in build with proper attributes', async () => {
const html = await fixture.readFile('../.vercel/output/static/index.html');
const $ = cheerio.load(html);
const img = $('img');
expect(img.attr('src').startsWith('/_vercel/image?url=_astr')).to.be.true;
expect(img.attr('loading')).to.equal('lazy');
expect(img.attr('width')).to.equal('225');
});
it('has proper vercel config', async () => {
const vercelConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json'));
expect(vercelConfig.images).to.deep.equal({
sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
domains: [],
});
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('has link to local image in dev with proper attributes', async () => {
const html = await fixture.fetch('/').then((res) => res.text());
const $ = cheerio.load(html);
const img = $('img');
expect(img.attr('src').startsWith('/_image?href=')).to.be.true;
expect(img.attr('loading')).to.equal('lazy');
expect(img.attr('width')).to.equal('225');
});
});
});

View file

@ -1,5 +1,5 @@
import { loadFixture } from './test-utils.js';
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
describe('Serverless prerender', () => {
/** @type {import('./test-utils').Fixture} */
@ -13,6 +13,6 @@ describe('Serverless prerender', () => {
it('build successful', async () => {
await fixture.build();
expect(fixture.readFile('/static/index.html')).to.be.ok;
expect(await fixture.readFile('../.vercel/output/static/index.html')).to.be.ok;
});
});

View file

@ -4639,10 +4639,22 @@ importers:
chai:
specifier: ^4.3.6
version: 4.3.6
cheerio:
specifier: ^1.0.0-rc.11
version: 1.0.0-rc.11
mocha:
specifier: ^9.2.2
version: 9.2.2
packages/integrations/vercel/test/fixtures/image:
dependencies:
'@astrojs/vercel':
specifier: workspace:*
version: link:../../..
astro:
specifier: workspace:*
version: link:../../../../../astro
packages/integrations/vercel/test/fixtures/no-output:
dependencies:
'@astrojs/vercel':