diff --git a/.changeset/lemon-pears-burn.md b/.changeset/lemon-pears-burn.md new file mode 100644 index 000000000..5c2370e50 --- /dev/null +++ b/.changeset/lemon-pears-burn.md @@ -0,0 +1,7 @@ +--- +'@cloudflare/next-on-pages': minor +--- + +Support for images via `/_next/image`, falling back to the raw image URL when image resizing is not available. + +Due to limitations with Cloudflare Pages, it is not currently possible to send requests through image resizing. diff --git a/packages/next-on-pages/docs/supported.md b/packages/next-on-pages/docs/supported.md index 05beb842b..02be65361 100644 --- a/packages/next-on-pages/docs/supported.md +++ b/packages/next-on-pages/docs/supported.md @@ -49,7 +49,7 @@ If you're application is using a package which relies on unsupported Node.js API | routes `missing` | ✅ | | routes `locale` | ✅ | | routes `middlewarePath` | ✅ | -| images1 | 🔄 | +| images1 | ✅ | | wildcard | ✅ | | overrides | ✅ | | cache | ❌ | @@ -59,7 +59,7 @@ If you're application is using a package which relies on unsupported Node.js API - 🔄: Not currently supported, but it's probably possible and we may add support in the future - ❌: Not supported and unlikely to be supported in the future -- _1_ - **images**: If you want to use `next/image`, you can provide your own [custom loader](https://nextjs.org/docs/api-reference/next/image#loader) and use Cloudflare Image Resizing, as per [Cloudflare's Image Resizing documentation](https://developers.cloudflare.com/images/image-resizing/integration-with-frameworks/#nextjs). +- _1_ - **images**: If you want to use `next/image`, there are two options; allow the library to take care of incoming requests, or using a custom loader. Requests are intercepted in the router and image resizing is attempted to be used (due to limitations with Pages, it is not currently possible to use image resizing) - if image resizing is not available, it falls back to fetching the normal image URL. Alternatively, you can provide your own [custom loader](https://nextjs.org/docs/api-reference/next/image#loader) and use Cloudflare Image Resizing, as per [Cloudflare's Image Resizing documentation](https://developers.cloudflare.com/images/image-resizing/integration-with-frameworks/#nextjs). ### next.config.js Properties diff --git a/packages/next-on-pages/templates/_worker.js/index.ts b/packages/next-on-pages/templates/_worker.js/index.ts index 4996889c7..f3baa3f76 100644 --- a/packages/next-on-pages/templates/_worker.js/index.ts +++ b/packages/next-on-pages/templates/_worker.js/index.ts @@ -1,5 +1,5 @@ import { handleRequest } from './handleRequest'; -import { adjustRequestForVercel } from './utils'; +import { adjustRequestForVercel, handleImageResizingRequest } from './utils'; import type { AsyncLocalStorage } from 'node:async_hooks'; declare const __NODE_ENV__: string; @@ -19,18 +19,26 @@ export default { { status: 503 } ); } - return envAsyncLocalStorage.run({ ...env, NODE_ENV: __NODE_ENV__ }, () => { - const adjustedRequest = adjustRequestForVercel(request); + return envAsyncLocalStorage.run( + { ...env, NODE_ENV: __NODE_ENV__ }, + async () => { + const url = new URL(request.url); + if (url.pathname.startsWith('/_next/image')) { + return handleImageResizingRequest(request, __CONFIG__.images); + } - return handleRequest( - { - request: adjustedRequest, - ctx, - assetsFetcher: env.ASSETS, - }, - __CONFIG__, - __BUILD_OUTPUT__ - ); - }); + const adjustedRequest = adjustRequestForVercel(request); + + return handleRequest( + { + request: adjustedRequest, + ctx, + assetsFetcher: env.ASSETS, + }, + __CONFIG__, + __BUILD_OUTPUT__ + ); + } + ); }, } as ExportedHandler<{ ASSETS: Fetcher }>; diff --git a/packages/next-on-pages/templates/_worker.js/utils/images.ts b/packages/next-on-pages/templates/_worker.js/utils/images.ts new file mode 100644 index 000000000..1d3b3db9e --- /dev/null +++ b/packages/next-on-pages/templates/_worker.js/utils/images.ts @@ -0,0 +1,149 @@ +/** + * Checks whether the given URL matches the given remote pattern from the Vercel build output + * images configuration. + * + * https://vercel.com/docs/build-output-api/v3/configuration#images + * + * @param url URL to check. + * @param pattern Remote pattern to match against. + * @returns Whether the URL matches the remote pattern. + */ +export function isRemotePatternMatch( + url: URL, + { protocol, hostname, port, pathname }: VercelImageRemotePattern +): boolean { + // Protocol must match if defined. + if (protocol && url.protocol.replace(/:$/, '') !== protocol) return false; + // Hostname must match regexp. + if (!new RegExp(hostname).test(url.hostname)) return false; + // Port must match regexp if defined. + if (port && !new RegExp(port).test(url.port)) return false; + // Pathname must match regexp if defined. + if (pathname && !new RegExp(pathname).test(url.pathname)) return false; + // All checks passed. + return true; +} + +type ResizingProperties = { + imageUrl: URL; + options: RequestInitCfPropertiesImage; +}; + +/** + * Derives the properties to use for image resizing from the incoming request, respecting the + * images configuration spec from the Vercel build output config. + * + * https://vercel.com/docs/build-output-api/v3/configuration#images + * + * @param request Incoming request. + * @param config Images configuration from the Vercel build output. + * @returns Resizing properties if the request is valid, otherwise undefined. + */ +export function getResizingProperties( + request: Request, + config?: VercelImagesConfig +): ResizingProperties | undefined { + if (request.method !== 'GET') return undefined; + + const { origin, searchParams } = new URL(request.url); + + const rawUrl = searchParams.get('url'); + const width = Number.parseInt(searchParams.get('w') ?? '', 10); + // 75 is the default quality - https://nextjs.org/docs/app/api-reference/components/image#quality + const quality = Number.parseInt(searchParams.get('q') ?? '75', 10); + + if (!rawUrl || Number.isNaN(width) || Number.isNaN(quality)) return undefined; + if (!config?.sizes?.includes(width)) return undefined; + if (quality < 0 || quality > 100) return undefined; + + const url = new URL(rawUrl, origin); + + // SVGs must be allowed by the config. + if (url.pathname.endsWith('.svg') && !config?.dangerouslyAllowSVG) { + return undefined; + } + + if ( + // Relative URL means same origin as deployment and is allowed. + !(rawUrl.startsWith('/') || rawUrl.startsWith('%2F')) && + // External image URL must be allowed by domains or remote patterns. + !config?.domains?.includes(url.hostname) && + !config?.remotePatterns?.find(pattern => isRemotePatternMatch(url, pattern)) + ) { + return undefined; + } + + const acceptHeader = request.headers.get('Accept') ?? ''; + const format = config?.formats + ?.find(format => acceptHeader.includes(format)) + ?.replace('image/', '') as VercelImageFormatWithoutPrefix; + + return { + imageUrl: url, + options: { width, quality, format }, + }; +} + +/** + * Formats the given response to match the images configuration spec from the Vercel build output + * config. + * + * Applies headers for `Content-Security-Policy` and `Content-Disposition`, if defined in the config. + * + * https://vercel.com/docs/build-output-api/v3/configuration#images + * + * @param resp Response to format. + * @param imageUrl Image URL that was resized. + * @param config Images configuration from the Vercel build output. + * @returns Formatted response. + */ +export function formatResp( + resp: Response, + imageUrl: URL, + config?: VercelImagesConfig +): Response { + const newHeaders = new Headers(resp.headers); + + if (config?.contentSecurityPolicy) { + newHeaders.set('Content-Security-Policy', config.contentSecurityPolicy); + } + + if (config?.contentDispositionType) { + const fileName = imageUrl.pathname.split('/').pop(); + const contentDisposition = fileName + ? `${config.contentDispositionType}; filename="${fileName}"` + : config.contentDispositionType; + + newHeaders.set('Content-Disposition', contentDisposition); + } + + return new Response(resp.body, { headers: newHeaders }); +} + +/** + * Handles image resizing requests. + * + * @param request Incoming request. + * @param config Images configuration from the Vercel build output. + * @returns Resized image response if the request is valid, otherwise a 400 response. + */ +export async function handleImageResizingRequest( + request: Request, + config?: VercelImagesConfig +): Promise { + const opts = getResizingProperties(request, config); + + if (!opts) { + return new Response('Invalid image resizing request', { status: 400 }); + } + + const { imageUrl } = opts; + + // NOTE: Pages does not support the RequestInit image resizing yet. + // const imageReq = new Request(imageUrl, { headers: request.headers }); + // const imageResp = await fetch(imageReq, { cf: { image: options } }); + // if (imageResp.status === 200) return formatResp(imageResp, imageUrl, config); + + const imageResp = await fetch(imageUrl, { headers: request.headers }); + return formatResp(imageResp, imageUrl, config); +} diff --git a/packages/next-on-pages/templates/_worker.js/utils/index.ts b/packages/next-on-pages/templates/_worker.js/utils/index.ts index 16e3bdf04..df2a0ff3f 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/index.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/index.ts @@ -3,3 +3,4 @@ export * from './request'; export * from './http'; export * from './pcre'; export * from './routing'; +export * from './images'; diff --git a/packages/next-on-pages/tests/templates/utils/images.test.ts b/packages/next-on-pages/tests/templates/utils/images.test.ts new file mode 100644 index 000000000..6fe389d34 --- /dev/null +++ b/packages/next-on-pages/tests/templates/utils/images.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from 'vitest'; +import { + formatResp, + getResizingProperties, + isRemotePatternMatch, +} from '../../../templates/_worker.js/utils'; + +describe('isRemotePatternMatch', () => { + test('hostname matches correctly', () => { + const config: VercelImageRemotePattern = { + hostname: '^via\\.placeholder\\.com$', + }; + + const validUrl = new URL('https://via.placeholder.com/images/1.jpg'); + expect(isRemotePatternMatch(validUrl, config)).toEqual(true); + + const invalidUrl = new URL('https://example.com/images/1.jpg'); + expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); + }); + + test('protocol matches correctly', () => { + const config: VercelImageRemotePattern = { + protocol: 'https', + hostname: '^via\\.placeholder\\.com$', + }; + + const validUrl = new URL('https://via.placeholder.com/images/1.jpg'); + expect(isRemotePatternMatch(validUrl, config)).toEqual(true); + + const invalidUrl = new URL('http://via.placeholder.com/images/1.jpg'); + expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); + }); + + test('port matches correctly', () => { + const config: VercelImageRemotePattern = { + hostname: '^via\\.placeholder\\.com$', + port: '9000', + }; + + const validUrl = new URL('https://via.placeholder.com:9000/images/1.jpg'); + expect(isRemotePatternMatch(validUrl, config)).toEqual(true); + + const invalidUrl = new URL('http://via.placeholder.com/images/1.jpg'); + expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); + }); + + test('pathname matches correctly', () => { + const config: VercelImageRemotePattern = { + hostname: '^via\\.placeholder\\.com$', + pathname: '^/images/.*$', + }; + + const validUrl = new URL('https://via.placeholder.com:9000/images/1.jpg'); + expect(isRemotePatternMatch(validUrl, config)).toEqual(true); + + const invalidUrl = new URL('http://via.placeholder.com/videos/1.mp4'); + expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); + }); +}); + +const baseUrl = 'https://localhost/_next/image?url='; +const baseValidUrl = `${baseUrl}%2Fimages%2F1.jpg`; +const baseConfig: VercelImagesConfig = { + domains: ['example.com'], + sizes: [640, 750, 828, 1080, 1200], + remotePatterns: [{ hostname: '^via\\.placeholder\\.com$' }], + formats: ['image/avif', 'image/webp'], +}; + +describe('getResizingProperties', () => { + test('invalid method fails', () => { + const url = new URL(baseValidUrl); + const req = new Request(url, { method: 'POST' }); + + expect(getResizingProperties(req)).toEqual(undefined); + }); + + describe('request search params', () => { + test('invalid url fails', () => { + const url = new URL(baseUrl); + const req = new Request(url); + + expect(getResizingProperties(req)).toEqual(undefined); + }); + + test('invalid width fails', () => { + const url = new URL(`${baseValidUrl}&w=abc`); + const req = new Request(url); + + expect(getResizingProperties(req)).toEqual(undefined); + }); + + test('invalid quality fails', () => { + const url = new URL(`${baseValidUrl}&w=100&q=abc`); + const req = new Request(url); + + expect(getResizingProperties(req)).toEqual(undefined); + }); + + test('invalid width in images config fails', () => { + const url = new URL(`${baseValidUrl}&w=100`); + const req = new Request(url); + + expect(getResizingProperties(req, baseConfig)).toEqual(undefined); + }); + + test('invalid quality (>100) fails', () => { + const url = new URL(`${baseValidUrl}&w=640&q=150`); + const req = new Request(url); + + expect(getResizingProperties(req, baseConfig)).toEqual(undefined); + }); + + test('invalid quality (<0) fails', () => { + const url = new URL(`${baseValidUrl}&w=640&q=-1`); + const req = new Request(url); + + expect(getResizingProperties(req, baseConfig)).toEqual(undefined); + }); + }); + + describe('relative (same origin) image', () => { + test('image with valid request options succeeds', () => { + const url = new URL(`${baseValidUrl}&w=640`); + const req = new Request(url); + + const result = getResizingProperties(req, baseConfig); + expect(result).toEqual({ + imageUrl: new URL('https://localhost/images/1.jpg'), + options: { format: undefined, width: 640, quality: 75 }, + }); + }); + + test('svg image fails when config disallows svgs', () => { + const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`); + const req = new Request(url); + const config = { ...baseConfig, dangerouslyAllowSVG: false }; + + expect(getResizingProperties(req, config)).toEqual(undefined); + }); + + test('svg image succeeds when config allows svgs', () => { + const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`); + const req = new Request(url); + const config = { ...baseConfig, dangerouslyAllowSVG: true }; + + const result = getResizingProperties(req, config); + expect(result).toEqual({ + imageUrl: new URL('https://localhost/images/1.svg'), + options: { format: undefined, width: 640, quality: 75 }, + }); + }); + + test('svg image succeeds when config allows them', () => { + const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`); + const req = new Request(url); + const config = { ...baseConfig, dangerouslyAllowSVG: true }; + + const result = getResizingProperties(req, config); + expect(result).toEqual({ + imageUrl: new URL('https://localhost/images/1.svg'), + options: { format: undefined, width: 640, quality: 75 }, + }); + }); + }); + + describe('external image', () => { + test('external image fails with disallowed domain', () => { + const url = new URL( + `${baseUrl}https%3A%2F%2Finvalid.com%2Fimage.jpg&w=640` + ); + const req = new Request(url); + + expect(getResizingProperties(req, baseConfig)).toEqual(undefined); + }); + + test('external image succeeds with allowed domain', () => { + const url = new URL( + `${baseUrl}https%3A%2F%2Fexample.com%2Fimage.jpg&w=640` + ); + const req = new Request(url); + + const result = getResizingProperties(req, baseConfig); + expect(result).toEqual({ + imageUrl: new URL('https://example.com/image.jpg'), + options: { format: undefined, width: 640, quality: 75 }, + }); + }); + + test('external image suceeds with allowed remote pattern', () => { + const url = new URL( + `${baseUrl}https%3A%2F%2Fvia.placeholder.com%2Fimage.jpg&w=640` + ); + const req = new Request(url); + + const result = getResizingProperties(req, baseConfig); + expect(result).toEqual({ + imageUrl: new URL('https://via.placeholder.com/image.jpg'), + options: { format: undefined, width: 640, quality: 75 }, + }); + }); + }); + + describe('request headers', () => { + test('return correct format for `accept` header (webp)', () => { + const url = new URL(`${baseValidUrl}&w=640`); + const req = new Request(url, { headers: { Accept: 'image/webp' } }); + + const result = getResizingProperties(req, baseConfig); + expect(result).toEqual({ + imageUrl: new URL('https://localhost/images/1.jpg'), + options: { format: 'webp', width: 640, quality: 75 }, + }); + }); + + test('return correct format for `accept` header (avif)', () => { + const url = new URL(`${baseValidUrl}&w=640`); + const req = new Request(url, { + headers: { Accept: 'image/avif,image/webp' }, + }); + + const result = getResizingProperties(req, baseConfig); + expect(result).toEqual({ + imageUrl: new URL('https://localhost/images/1.jpg'), + options: { format: 'avif', width: 640, quality: 75 }, + }); + }); + }); +}); + +describe('formatResp', () => { + test('applies content security policy from the config', () => { + const config = { ...baseConfig, contentSecurityPolicy: 'default-src' }; + const imageUrl = new URL('https://localhost/images/1.jpg'); + + const newResp = formatResp(new Response(), imageUrl, config); + expect(newResp.headers.get('Content-Security-Policy')).toEqual( + 'default-src' + ); + }); + + test('applies content disposition from the config', () => { + const config = { ...baseConfig, contentDispositionType: 'inline' }; + const imageUrl = new URL('https://localhost/images/1.jpg'); + + const newResp = formatResp(new Response(), imageUrl, config); + expect(newResp.headers.get('Content-Disposition')).toEqual( + 'inline; filename="1.jpg"' + ); + }); +}); diff --git a/packages/next-on-pages/vercel.types.d.ts b/packages/next-on-pages/vercel.types.d.ts index 0cf48d6df..4433d8ab6 100644 --- a/packages/next-on-pages/vercel.types.d.ts +++ b/packages/next-on-pages/vercel.types.d.ts @@ -85,11 +85,20 @@ type VercelHandler = { }; type VercelImageFormat = 'image/avif' | 'image/webp'; +type VercelImageFormatWithoutPrefix = StripPrefix; +type StripPrefix = T extends `${K}${infer V}` ? V : T; + +type VercelImageRemotePattern = { + protocol?: 'http' | 'https'; + hostname: string; + port?: string; + pathname?: string; +}; type VercelImagesConfig = { sizes: number[]; domains: string[]; - remotePatterns?: string[]; + remotePatterns?: VercelImageRemotePattern[]; minimumCacheTTL?: number; // seconds formats?: VercelImageFormat[]; dangerouslyAllowSVG?: boolean;