diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 89be65fdd..80e4af319 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -22,6 +22,7 @@ ".": "./dist/index.js", "./entrypoints/server.advanced.js": "./dist/entrypoints/server.advanced.js", "./entrypoints/server.directory.js": "./dist/entrypoints/server.directory.js", + "./image-service": "./dist/entrypoints/image-service.js", "./package.json": "./package.json" }, "files": [ diff --git a/packages/cloudflare/src/entrypoints/image-service.ts b/packages/cloudflare/src/entrypoints/image-service.ts new file mode 100644 index 000000000..ea607ca7a --- /dev/null +++ b/packages/cloudflare/src/entrypoints/image-service.ts @@ -0,0 +1,400 @@ +import type { + AstroConfig, + ExternalImageService, + ImageMetadata, + ImageTransform, + RemotePattern, +} from 'astro'; + +type SrcSetValue = { + transform: ImageTransform; + descriptor?: string; + attributes?: Record; +}; + +function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata { + return typeof src === 'object'; +} + +function isRemotePath(src: string) { + return /^(http|ftp|https|ws):?\/\//.test(src) || src.startsWith('data:'); +} + +function matchHostname(url: URL, hostname?: string, allowWildcard?: boolean) { + if (!hostname) { + return true; + } else if (!allowWildcard || !hostname.startsWith('*')) { + return hostname === url.hostname; + } else if (hostname.startsWith('**.')) { + const slicedHostname = hostname.slice(2); // ** length + return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname); + } else if (hostname.startsWith('*.')) { + const slicedHostname = hostname.slice(1); // * length + const additionalSubdomains = url.hostname + .replace(slicedHostname, '') + .split('.') + .filter(Boolean); + return additionalSubdomains.length === 1; + } + + return false; +} +export function matchPort(url: URL, port?: string) { + return !port || port === url.port; +} + +export function matchProtocol(url: URL, protocol?: string) { + return !protocol || protocol === url.protocol.slice(0, -1); +} + +export function matchPathname(url: URL, pathname?: string, allowWildcard?: boolean) { + if (!pathname) { + return true; + } else if (!allowWildcard || !pathname.endsWith('*')) { + return pathname === url.pathname; + } else if (pathname.endsWith('/**')) { + const slicedPathname = pathname.slice(0, -2); // ** length + return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname); + } else if (pathname.endsWith('/*')) { + const slicedPathname = pathname.slice(0, -1); // * length + const additionalPathChunks = url.pathname + .replace(slicedPathname, '') + .split('/') + .filter(Boolean); + return additionalPathChunks.length === 1; + } + + return false; +} + +function matchPattern(url: URL, remotePattern: RemotePattern) { + return ( + matchProtocol(url, remotePattern.protocol) && + matchHostname(url, remotePattern.hostname, true) && + matchPort(url, remotePattern.port) && + matchPathname(url, remotePattern.pathname, true) + ); +} + +function isRemoteAllowed( + src: string, + { + domains = [], + remotePatterns = [], + }: Partial> +): boolean { + if (!isRemotePath(src)) return false; + + const url = new URL(src); + return ( + domains.some((domain) => matchHostname(url, domain)) || + remotePatterns.some((remotePattern) => matchPattern(url, remotePattern)) + ); +} + +const VALID_SUPPORTED_FORMATS = [ + 'jpeg', + 'jpg', + 'png', + 'tiff', + 'webp', + 'gif', + 'svg', + 'avif', +] as const; + +const DEFAULT_OUTPUT_FORMAT = 'webp' as const; + +function isString(path: unknown): path is string { + return typeof path === 'string' || path instanceof String; +} + +function removeTrailingForwardSlash(path: string) { + return path.endsWith('/') ? path.slice(0, path.length - 1) : path; +} + +function removeLeadingForwardSlash(path: string) { + return path.startsWith('/') ? path.substring(1) : path; +} + +function trimSlashes(path: string) { + return path.replace(/^\/|\/$/g, ''); +} + +function joinPaths(...paths: (string | undefined)[]) { + return paths + .filter(isString) + .map((path, i) => { + if (i === 0) { + return removeTrailingForwardSlash(path); + } else if (i === paths.length - 1) { + return removeLeadingForwardSlash(path); + } else { + return trimSlashes(path); + } + }) + .join('/'); +} + +/** + * Returns the final dimensions of an image based on the user's options. + * + * For local images: + * - If the user specified both width and height, we'll use those. + * - If the user specified only one of them, we'll use the original image's aspect ratio to calculate the other. + * - If the user didn't specify either, we'll use the original image's dimensions. + * + * For remote images: + * - Widths and heights are always required, so we'll use the user's specified width and height. + */ +function getTargetDimensions(options: ImageTransform) { + let targetWidth = options.width; + let targetHeight = options.height; + if (isESMImportedImage(options.src)) { + const aspectRatio = options.src.width / options.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 = options.src.width; + targetHeight = options.src.height; + } + } + + // TypeScript doesn't know this, but because of previous hooks we always know that targetWidth and targetHeight are defined + return { + targetWidth: targetWidth!, + targetHeight: targetHeight!, + }; +} + +const service: ExternalImageService = { + validateOptions: (options /*, imageConfig: AstroConfig['image']*/) => { + // add custom global CF image service options + // const serviceConfig = imageConfig.service.config; + + // need to add checks for limits + // https://developers.cloudflare.com/images/image-resizing/format-limitations/#format-limitations + + // `src` is missing or is `undefined`. + if (!options.src || (typeof options.src !== 'string' && typeof options.src !== 'object')) { + throw new Error('ExpectedImage'); + // throw new AstroError({ + // ...AstroErrorData.ExpectedImage, + // message: AstroErrorData.ExpectedImage.message( + // JSON.stringify(options.src), + // typeof options.src, + // JSON.stringify(options, (_, v) => (v === undefined ? null : v)) + // ), + // }); + } + + if (!isESMImportedImage(options.src)) { + // User passed an `/@fs/` path or a filesystem path instead of the full image. + if ( + options.src.startsWith('/@fs/') || + (!isRemotePath(options.src) && !options.src.startsWith('/')) + ) { + throw new Error('LocalImageUsedWrongly'); + // throw new AstroError({ + // ...AstroErrorData.LocalImageUsedWrongly, + // message: AstroErrorData.LocalImageUsedWrongly.message(options.src), + // }); + } + + // For remote images, width and height are explicitly required as we can't infer them from the file + let missingDimension: 'width' | 'height' | 'both' | undefined; + if (!options.width && !options.height) { + missingDimension = 'both'; + } else if (!options.width && options.height) { + missingDimension = 'width'; + } else if (options.width && !options.height) { + missingDimension = 'height'; + } + + if (missingDimension) { + throw new Error('MissingImageDimension'); + // throw new AstroError({ + // ...AstroErrorData.MissingImageDimension, + // message: AstroErrorData.MissingImageDimension.message(missingDimension, options.src), + // }); + } + } else { + if (!VALID_SUPPORTED_FORMATS.includes(options.src.format)) { + throw new Error('UnsupportedImageFormat'); + // throw new AstroError({ + // ...AstroErrorData.UnsupportedImageFormat, + // message: AstroErrorData.UnsupportedImageFormat.message( + // options.src.format, + // options.src.src, + // VALID_SUPPORTED_FORMATS + // ), + // }); + } + + if (options.widths && options.densities) { + throw new Error('IncompatibleDescriptorOptions'); + // throw new AstroError(AstroErrorData.IncompatibleDescriptorOptions); + } + + // We currently do not support processing SVGs, so whenever the input format is a SVG, force the output to also be one + if (options.src.format === 'svg') { + options.format = 'svg'; + } + + if ( + (options.src.format === 'svg' && options.format !== 'svg') || + (options.src.format !== 'svg' && options.format === 'svg') + ) { + throw new Error('UnsupportedImageConversion'); + // throw new AstroError(AstroErrorData.UnsupportedImageConversion); + } + } + + // If the user didn't specify a format, we'll default to `webp`. It offers the best ratio of compatibility / quality + // In the future, hopefully we can replace this with `avif`, alas, Edge. See https://caniuse.com/avif + if (!options.format) { + options.format = DEFAULT_OUTPUT_FORMAT; + } + + // Sometimes users will pass number generated from division, which can result in floating point numbers + if (options.width) options.width = Math.round(options.width); + if (options.height) options.height = Math.round(options.height); + + return options; + }, + getHTMLAttributes: (options) => { + const { targetWidth, targetHeight } = getTargetDimensions(options); + 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', + }; + }, + getSrcSet(options) { + const srcSet: SrcSetValue[] = []; + const { targetWidth } = getTargetDimensions(options); + const { widths, densities } = options; + const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT; + + // NOTE: Depending on the cloudflare fit value, the image might never be enlarged + // For remote images, we don't know the original image's dimensions, so we cannot know the maximum width + // It is ultimately the user's responsibility to make sure they don't request images larger than the original + let imageWidth = options.width; + let maxWidth = Infinity; + + // However, if it's an imported image, we can use the original image's width as a maximum width + if (isESMImportedImage(options.src)) { + imageWidth = options.src.width; + maxWidth = imageWidth; + } + + // Since `widths` and `densities` ultimately control the width and height of the image, + // we don't want the dimensions the user specified, we'll create those ourselves. + const { + width: transformWidth, + height: transformHeight, + ...transformWithoutDimensions + } = options; + + // Collect widths to generate from specified densities or widths + const allWidths: { maxTargetWidth: number; descriptor: `${number}x` | `${number}w` }[] = []; + if (densities) { + // Densities can either be specified as numbers, or descriptors (ex: '1x'), we'll convert them all to numbers + const densityValues = densities.map((density) => { + if (typeof density === 'number') { + return density; + } else { + return parseFloat(density); + } + }); + + // Calculate the widths for each density, rounding to avoid floats. + const densityWidths = densityValues + .sort() + .map((density) => Math.round(targetWidth * density)); + + allWidths.push( + ...densityWidths.map((width, index) => ({ + maxTargetWidth: Math.min(width, maxWidth), + descriptor: `${densityValues[index]}x` as const, + })) + ); + } else if (widths) { + allWidths.push( + ...widths.map((width) => ({ + maxTargetWidth: Math.min(width, maxWidth), + descriptor: `${width}w` as const, + })) + ); + } + + // Caution: The logic below is a bit tricky, as we need to make sure we don't generate the same image multiple times + // When making changes, make sure to test with different combinations of local/remote images widths, densities, and dimensions etc. + for (const { maxTargetWidth, descriptor } of allWidths) { + const srcSetTransform: ImageTransform = { ...transformWithoutDimensions }; + + // Only set the width if it's different from the original image's width, to avoid generating the same image multiple times + if (maxTargetWidth !== imageWidth) { + srcSetTransform.width = maxTargetWidth; + } else { + // If the width is the same as the original image's width, and we have both dimensions, it probably means + // it's a remote image, so we'll use the user's specified dimensions to avoid recreating the original image unnecessarily + if (options.width && options.height) { + srcSetTransform.width = options.width; + srcSetTransform.height = options.height; + } + } + + srcSet.push({ + transform: srcSetTransform, + descriptor, + attributes: { + type: `image/${targetFormat}`, + }, + }); + } + + return srcSet; + }, + getURL: (options, imageConfig) => { + const resizingParams = []; + if (options.width) resizingParams.push(`width=${options.width}`); + if (options.height) resizingParams.push(`height=${options.height}`); + if (options.quality) resizingParams.push(`quality=${options.quality}`); + if (options.fit) resizingParams.push(`fit=${options.fit}`); + if (options.format) resizingParams.push(`format=${options.format}`); + + let imageSource = ''; + if (isESMImportedImage(options.src)) { + imageSource = options.src.src; + } else if (isRemoteAllowed(options.src, imageConfig)) { + imageSource = options.src; + } else { + // If it's not an imported image, nor is it allowed using the current domains or remote patterns, we'll just return the original URL + return options.src; + } + + const imageEndpoint = joinPaths( + //@ts-ignore + import.meta.env.BASE_URL, + '/cdn-cgi/image', + resizingParams.join(','), + imageSource + ); + console.log(imageEndpoint); + + return imageEndpoint; + }, +}; + +export default service; diff --git a/packages/cloudflare/src/getAdapter.ts b/packages/cloudflare/src/getAdapter.ts index 0a4879557..e8acb283a 100644 --- a/packages/cloudflare/src/getAdapter.ts +++ b/packages/cloudflare/src/getAdapter.ts @@ -13,7 +13,8 @@ export function getAdapter({ serverOutput: 'stable', assets: { supportKind: 'stable', - isSharpCompatible: false, + // FIXME: UNDO THIS BEFORE RELEASE + isSharpCompatible: true, isSquooshCompatible: false, }, }; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 0ee5fd945..0d1b85763 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,6 +1,7 @@ import type { AstroConfig, AstroIntegration, RouteData } from 'astro'; import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; +import { sharpImageService } from 'astro/config'; import { AstroError } from 'astro/errors'; import esbuild from 'esbuild'; import { Miniflare } from 'miniflare'; @@ -104,7 +105,7 @@ export default function createIntegration(args?: Options): AstroIntegration { return { name: '@astrojs/cloudflare', hooks: { - 'astro:config:setup': ({ config, updateConfig }) => { + 'astro:config:setup': ({ command, config, updateConfig }) => { updateConfig({ build: { client: new URL(`.${config.base}`, config.outDir), @@ -121,6 +122,14 @@ export default function createIntegration(args?: Options): AstroIntegration { }), ], }, + image: { + service: + command === 'dev' + ? sharpImageService() + : { + entrypoint: '@astrojs/cloudflare/image-service', + }, + }, }); }, 'astro:config:done': ({ setAdapter, config, logger }) => {