diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index 2b4c3e560e14..817dd8f44880 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -23,6 +23,7 @@ "./entrypoints/middleware.js": "./dist/entrypoints/middleware.js", "./image-service": "./dist/entrypoints/image-service.js", "./image-endpoint": "./dist/entrypoints/image-endpoint.js", + "./image-transform-endpoint": "./dist/entrypoints/image-transform-endpoint.js", "./handler": "./dist/utils/handler.js", "./package.json": "./package.json" }, diff --git a/packages/integrations/cloudflare/src/entrypoints/image-transform-endpoint.ts b/packages/integrations/cloudflare/src/entrypoints/image-transform-endpoint.ts new file mode 100644 index 000000000000..b8be3e847693 --- /dev/null +++ b/packages/integrations/cloudflare/src/entrypoints/image-transform-endpoint.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from 'astro'; +import { transform } from '../utils/image-binding-transform.js'; + +export const prerender = false; + +// @ts-expect-error The Header types between libdom and @cloudflare/workers-types are causing issues +export const GET: APIRoute = async (ctx) => { + // @ts-expect-error The runtime locals types are not populated here + return transform(ctx.request.url, ctx.locals.runtime.env.IMAGES, ctx.locals.runtime.env.ASSETS); +}; diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 2d706e7b3d41..1bdd49e697b6 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -100,6 +100,23 @@ export type Options = { */ sessionKVBindingName?: string; + /** + * When configured as `cloudflare-binding`, the Cloudflare Images binding will be used to transform images: + * - https://developers.cloudflare.com/images/transform-images/bindings/ + * + * By default, this will use the "IMAGES" binding name, but this can be customised in your `wrangler.json`: + * + * ```json + * { + * "images": { + * "binding": "IMAGES" // <-- this should match `imagesBindingName` + * } + * } + * ``` + * + */ + imagesBindingName?: string; + /** * This configuration option allows you to specify a custom entryPoint for your Cloudflare Worker. * The entry point is the file that will be executed when your Worker is invoked. @@ -165,6 +182,17 @@ export default function createIntegration(args?: Options): AstroIntegration { const isBuild = command === 'build'; + if (isBuild && args?.imageService === 'cloudflare-binding') { + const bindingName = args?.imagesBindingName ?? 'IMAGES'; + + logger.info( + `Enabling image processing with Cloudflare Images for production with the "${bindingName}" Images binding.`, + ); + logger.info( + `If you see the error "Invalid binding \`${bindingName}\`" in your build output, you need to add the binding to your wrangler config file.`, + ); + } + if (!session?.driver) { const sessionDir = isBuild ? undefined : createCodegenDir(); const bindingName = args?.sessionKVBindingName ?? 'SESSION'; @@ -365,6 +393,9 @@ export default function createIntegration(args?: Options): AstroIntegration { 'globalThis.__ASTRO_SESSION_BINDING_NAME': JSON.stringify( args?.sessionKVBindingName ?? 'SESSION', ), + 'globalThis.__ASTRO_IMAGES_BINDING_NAME': JSON.stringify( + args?.imagesBindingName ?? 'IMAGES', + ), ...vite.define, }; } diff --git a/packages/integrations/cloudflare/src/utils/handler.ts b/packages/integrations/cloudflare/src/utils/handler.ts index 8b54836f84c3..9c6ff9b50026 100644 --- a/packages/integrations/cloudflare/src/utils/handler.ts +++ b/packages/integrations/cloudflare/src/utils/handler.ts @@ -32,6 +32,10 @@ declare global { // eslint-disable-next-line no-var var __ASTRO_SESSION_BINDING_NAME: string; + // This is not a real global, but is injected using Vite define to allow us to specify the Images binding name in the config. + // eslint-disable-next-line no-var + var __ASTRO_IMAGES_BINDING_NAME: string; + // Just used to pass the KV binding to unstorage. // eslint-disable-next-line no-var var __env__: Partial; diff --git a/packages/integrations/cloudflare/src/utils/image-binding-transform.ts b/packages/integrations/cloudflare/src/utils/image-binding-transform.ts new file mode 100644 index 000000000000..4ab4bbb71684 --- /dev/null +++ b/packages/integrations/cloudflare/src/utils/image-binding-transform.ts @@ -0,0 +1,46 @@ +// @ts-expect-error Not sure how to make this typecheck properly +import { imageConfig } from 'astro:assets'; +import { isRemotePath } from '@astrojs/internal-helpers/path'; +import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; + +import type { + Fetcher, + ImagesBinding, + ImageTransform, + ReadableStream, +} from '@cloudflare/workers-types'; + +export async function transform(rawUrl: string, images: ImagesBinding, assets: Fetcher) { + const url = new URL(rawUrl); + + const href = url.searchParams.get('href'); + + if (!href || (isRemotePath(href) && !isRemoteAllowed(href, imageConfig))) { + return new Response('Forbidden', { status: 403 }); + } + + const imageSrc = new URL(href, url.origin); + const content = await (isRemotePath(href) ? fetch(imageSrc) : assets.fetch(imageSrc)); + if (!content.body) { + return new Response(null, { status: 404 }); + } + const input = images.input(content.body as ReadableStream); + + const format = url.searchParams.get('f'); + + if (!format || !['avif', 'webp', 'jpeg'].includes(format)) { + return new Response(`The "${format}" format is not supported`, { status: 400 }); + } + + return ( + await input + .transform({ + width: url.searchParams.has('w') ? parseInt(url.searchParams.get('w')!) : undefined, + height: url.searchParams.has('h') ? parseInt(url.searchParams.get('h')!) : undefined, + // `quality` is documented, but doesn't appear to work in manual testing... + // quality: url.searchParams.get('q'), + fit: url.searchParams.get('fit') as ImageTransform['fit'], + }) + .output({ format: `image/${format as 'webp' | 'avif' | 'jpeg'}` }) + ).response(); +} diff --git a/packages/integrations/cloudflare/src/utils/image-config.ts b/packages/integrations/cloudflare/src/utils/image-config.ts index 967b1b014390..a309f5007f08 100644 --- a/packages/integrations/cloudflare/src/utils/image-config.ts +++ b/packages/integrations/cloudflare/src/utils/image-config.ts @@ -1,7 +1,12 @@ import type { AstroConfig, AstroIntegrationLogger, HookParameters } from 'astro'; import { passthroughImageService, sharpImageService } from 'astro/config'; -export type ImageService = 'passthrough' | 'cloudflare' | 'compile' | 'custom'; +export type ImageService = + | 'passthrough' + | 'cloudflare' + | 'cloudflare-binding' + | 'compile' + | 'custom'; export function setImageConfig( service: ImageService, @@ -21,6 +26,17 @@ export function setImageConfig( ? sharpImageService() : { entrypoint: '@astrojs/cloudflare/image-service' }, }; + case 'cloudflare-binding': + return { + ...config, + service: command === 'dev' ? sharpImageService() : undefined, + endpoint: + command === 'dev' + ? undefined + : { + entrypoint: '@astrojs/cloudflare/image-transform-endpoint', + }, + }; case 'compile': return {