diff --git a/.changeset/fix-cloudflare-binding-image-optimization.md b/.changeset/fix-cloudflare-binding-image-optimization.md new file mode 100644 index 000000000000..45552612b6c9 --- /dev/null +++ b/.changeset/fix-cloudflare-binding-image-optimization.md @@ -0,0 +1,19 @@ +--- +'@astrojs/cloudflare': minor +--- + +Adds opt-in build-time image optimization for the `cloudflare-binding` image service. + +When enabled, the Cloudflare IMAGES binding transforms static images in the workerd prerender environment, and the optimized bytes are written directly to the output directory. If the binding fails, it falls back to Sharp. + +To opt in, use the compound configuration form: + +```js +export default defineConfig({ + adapter: cloudflare({ + imageService: { build: 'cloudflare-binding', runtime: 'cloudflare-binding' }, + }), +}); +``` + +The string shorthand `imageService: 'cloudflare-binding'` preserves the current runtime-only behavior and is unaffected. diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index a758409cad41..0f9348957534 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -143,9 +143,13 @@ export default function createIntegration({ let hasUserBuildImageService = false; let compileImageConfig: CompileImageConfig | null = null; - const { buildService, runtimeService } = normalizeImageServiceConfig(imageService); + const { buildService, runtimeService, transformAtBuild } = + normalizeImageServiceConfig(imageService); const needsImagesBinding = runtimeService === 'cloudflare-binding'; const hasBuildImageService = buildService === 'compile' || buildService === 'custom'; + // Opt-in: user explicitly requested build-time image transformation via the compound config. + // The string shorthand `'cloudflare-binding'` keeps the historical runtime-only behavior. + const isBindingBuild = transformAtBuild && buildService === 'cloudflare-binding'; return { name: '@astrojs/cloudflare', @@ -184,10 +188,10 @@ export default function createIntegration({ const needsSessionKVBinding = usesCloudflareKVSessionDriver(session); - // In dev, `compile` needs the IMAGES binding for real transforms - // (the image-transform-endpoint uses it). At build time, + // In dev, `compile` and binding builds need the IMAGES binding for real + // transforms (the image-transform-endpoint uses it). At build time, // `compile` uses Sharp on the Node side instead. - const needsImagesBindingForDev = isCompile && command === 'dev'; + const needsImagesBindingForDev = (isCompile || isBindingBuild) && command === 'dev'; const usesContentCollections = hasContentCollectionsConfig(config.srcDir); const prebundleContentRuntime = command === 'dev' && usesContentCollections; const isTypeGenPhase = command === 'build' || command === 'sync'; @@ -213,7 +217,9 @@ export default function createIntegration({ ...(queues?.producers?.length && { queues: { producers: queues.producers }, }), - ...(needsImagesBinding && + // `isBindingBuild` needs the IMAGES binding in the prerender + // worker even when the runtime service is `passthrough`. + ...((needsImagesBinding || isBindingBuild) && !restWorkerConfig.images && { images: { binding: imagesBindingName }, }), @@ -415,7 +421,7 @@ export default function createIntegration({ // cannot be resolved yet. The plugin serializes this object lazily // at load time, after the mutation below has happened. compileImageConfig: - hasBuildImageService && command !== 'dev' + (hasBuildImageService || isBindingBuild) && command !== 'dev' ? (compileImageConfig = { base: config.base, assetsPrefix: @@ -424,6 +430,7 @@ export default function createIntegration({ : undefined, imageServiceEntrypoint: '@astrojs/cloudflare/image-service-workerd', buildAssets: config.build.assets ?? '_astro', + transformWithBinding: isBindingBuild, }) : null, cacheProviderEnabled: needsWorkerCache, @@ -503,7 +510,7 @@ export default function createIntegration({ // these variables at build time. loadWranglerEnv(config.root, cloudflareOptions.configPath, logger); }, - 'astro:build:start': ({ setPrerenderer }) => { + 'astro:build:start': ({ setPrerenderer, logger }) => { if (prerenderEnvironment === 'workerd') { setPrerenderer( createCloudflarePrerenderer({ @@ -514,9 +521,11 @@ export default function createIntegration({ trailingSlash: _config.trailingSlash, cfPluginConfig, hasBuildImageService, + hasBindingImageService: isBindingBuild, userImageServiceEntrypoint: hasUserBuildImageService ? resolveImageServiceEntrypoint(_config.image.service.entrypoint, _config.root) : undefined, + logger, }), ); } diff --git a/packages/integrations/cloudflare/src/prerenderer.ts b/packages/integrations/cloudflare/src/prerenderer.ts index a3607f1b3d63..6093ba945b4d 100644 --- a/packages/integrations/cloudflare/src/prerenderer.ts +++ b/packages/integrations/cloudflare/src/prerenderer.ts @@ -1,25 +1,53 @@ import type { AstroConfig, + AstroIntegrationLogger, AstroPrerenderer, AssetsGlobalStaticImagesList, + ImageTransform, PathWithRoute, } from 'astro'; import { preview, createLogger, type PreviewServer as VitePreviewServer } from 'vite'; import { fileURLToPath } from 'node:url'; +import { createReadStream, createWriteStream, existsSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; +import { Readable } from 'node:stream'; +import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; +import { pipeline } from 'node:stream/promises'; +import { join, dirname } from 'node:path'; +import { isRemotePath } from '@astrojs/internal-helpers/path'; import { cloudflare as cfVitePlugin, type PluginConfig } from '@cloudflare/vite-plugin'; import { serializeRouteData, deserializeRouteData } from 'astro/app/manifest'; import type { StaticPathsResponse, PrerenderRequest, + SerializedStaticImageEntry, StaticImagesResponse, } from './prerender-types.js'; import { STATIC_PATHS_ENDPOINT, PRERENDER_ENDPOINT, STATIC_IMAGES_ENDPOINT, + IMAGE_TRANSFORM_ENDPOINT, } from './utils/prerender-constants.js'; +/** + * How many images to request from the prerender worker at once. Each response streams + * straight to disk, so peak memory stays proportional to this many images rather than + * to the whole image set. + */ +const IMAGE_TRANSFORM_CONCURRENCY = 8; + +/** Maps Astro's transform options onto the query parameters `/_image` expects. */ +const IMAGE_TRANSFORM_PARAMS: Record = { + w: 'width', + h: 'height', + q: 'quality', + f: 'format', + fit: 'fit', + position: 'position', + background: 'background', +}; + interface CloudflarePrerendererOptions { root: AstroConfig['root']; serverDir: AstroConfig['build']['server']; @@ -28,7 +56,105 @@ interface CloudflarePrerendererOptions { trailingSlash: AstroConfig['trailingSlash']; cfPluginConfig: PluginConfig; hasBuildImageService: boolean; + /** When true, images are optimized by the IMAGES binding in workerd during the build. */ + hasBindingImageService: boolean; userImageServiceEntrypoint?: string; + logger: AstroIntegrationLogger; +} + +/** Runs `fn` over `items`, keeping at most `limit` calls in flight. */ +async function forEachWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + let next = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + await fn(items[next++]); + } + }); + await Promise.all(workers); +} + +function createImageTransformUrl( + serverUrl: string, + originalPath: string, + transform: Record, +): string { + const url = new URL(IMAGE_TRANSFORM_ENDPOINT, serverUrl); + url.searchParams.set('href', originalPath); + + for (const [param, key] of Object.entries(IMAGE_TRANSFORM_PARAMS)) { + const value = transform[key]; + if (value) { + url.searchParams.set(param, value.toString()); + } + } + + return url.toString(); +} + +/** + * Locates the unoptimized original on disk. Astro emits it into the prerender output + * (and deletes it once the transforms are generated), so it is not in the client + * directory the worker's ASSETS binding serves. Falls back to the source file, which + * Astro records for images imported from `src`. + */ +function findOriginalImage( + serverDir: URL, + clientDir: URL, + originalPath: string, + originalSrcPath: string | undefined, +): string | undefined { + const candidates = [ + join(fileURLToPath(new URL('.prerender/', serverDir)), originalPath), + join(fileURLToPath(clientDir), originalPath), + ...(originalSrcPath ? [originalSrcPath] : []), + ]; + return candidates.find((candidate) => existsSync(candidate)); +} + +/** + * Requests one optimized image from the prerender worker and streams the response body + * directly into the client output directory. The original is streamed up as the request + * body, so neither side ever holds a whole image in memory. + */ +async function writeTransformedImage( + serverUrl: string, + clientDir: URL, + originalPath: string, + finalPath: string, + transform: Record, + sourcePath: string | undefined, +): Promise { + const response = await fetch(createImageTransformUrl(serverUrl, originalPath, transform), { + method: 'POST', + // Remote images have no local original; the worker fetches those itself. + ...(sourcePath + ? { + body: Readable.toWeb(createReadStream(sourcePath)) as unknown as BodyInit, + // Required by Node's fetch whenever the body is a stream. + duplex: 'half', + } + : {}), + } as RequestInit); + + if (!response.ok || !response.body) { + // The body can be a full error page, so keep only enough of it to be useful. + const body = (await response.text().catch(() => '')).replace(/\s+/g, ' ').trim(); + const details = body ? `: ${body.slice(0, 200)}` : ''; + throw new Error( + `the prerender server responded ${response.status} ${response.statusText}${details}`, + ); + } + + const outputPath = join(fileURLToPath(clientDir), finalPath); + await mkdir(dirname(outputPath), { recursive: true }); + // `fetch` types the body as the DOM `ReadableStream`, which is structurally + // identical to but nominally distinct from the `node:stream/web` one. + const body = response.body as unknown as NodeReadableStream; + await pipeline(Readable.fromWeb(body), createWriteStream(outputPath)); } /** @@ -43,7 +169,9 @@ export function createCloudflarePrerenderer({ trailingSlash, cfPluginConfig, hasBuildImageService, + hasBindingImageService, userImageServiceEntrypoint, + logger, }: CloudflarePrerendererOptions): AstroPrerenderer { let previewServer: VitePreviewServer | undefined; let serverUrl: string; @@ -148,46 +276,102 @@ export function createCloudflarePrerenderer({ return response; }, - collectStaticImages: hasBuildImageService - ? async (): Promise => { - const response = await fetch(`${serverUrl}${STATIC_IMAGES_ENDPOINT}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - const body = await response.text(); - const details = body ? `\n${body}` : ''; - throw new Error( - `Failed to get static images from the Cloudflare prerender server (${response.status}: ${response.statusText}).${details}`, - ); - } + collectStaticImages: + hasBuildImageService || hasBindingImageService + ? async (): Promise => { + const response = await fetch(`${serverUrl}${STATIC_IMAGES_ENDPOINT}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); - const entries: StaticImagesResponse = await response.json(); + if (!response.ok) { + const body = await response.text(); + const details = body ? `\n${body}` : ''; + throw new Error( + `Failed to get static images from the Cloudflare prerender server (${response.status}: ${response.statusText}).${details}`, + ); + } - globalThis.astroAsset ??= {}; - if (userImageServiceEntrypoint) { - const mod = await import(userImageServiceEntrypoint); - globalThis.astroAsset.imageService = mod.default ?? mod; - } else { - const { default: sharpService } = await import('astro/assets/services/sharp'); - globalThis.astroAsset.imageService = sharpService; - } + const entries: StaticImagesResponse = await response.json(); + + // Transforms left in this map fall through to the Node-side image + // service (the user-configured service, or Sharp). + const staticImages: AssetsGlobalStaticImagesList = new Map(); + const deferToNodeImageService = ( + entry: SerializedStaticImageEntry, + t: SerializedStaticImageEntry['transforms'][number], + ) => { + let existing = staticImages.get(entry.originalPath); + if (!existing) { + existing = { originalSrcPath: entry.originalSrcPath, transforms: new Map() }; + staticImages.set(entry.originalPath, existing); + } + existing.transforms.set(t.hash, { + finalPath: t.finalPath, + // Serialized over HTTP, so it arrives as a plain object. + transform: t.transform as ImageTransform, + }); + }; - const staticImages: AssetsGlobalStaticImagesList = new Map(); - for (const entry of entries) { - const transforms = new Map(); - for (const t of entry.transforms) { - transforms.set(t.hash, { finalPath: t.finalPath, transform: t.transform }); + if (hasBindingImageService) { + // Pull each optimized image out of workerd on its own request so the + // bytes stream to disk instead of being buffered into one response. + const jobs = entries.flatMap((entry) => { + const sourcePath = isRemotePath(entry.originalPath) + ? undefined + : findOriginalImage( + serverDir, + clientDir, + entry.originalPath, + entry.originalSrcPath, + ); + return entry.transforms.map((t) => ({ entry, t, sourcePath })); + }); + await forEachWithConcurrency( + jobs, + IMAGE_TRANSFORM_CONCURRENCY, + async ({ entry, t, sourcePath }) => { + try { + await writeTransformedImage( + serverUrl, + clientDir, + entry.originalPath, + t.finalPath, + t.transform, + sourcePath, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn( + `Could not optimize "${entry.originalPath}" with the Cloudflare IMAGES binding (${message}). Falling back to the local image service.`, + ); + deferToNodeImageService(entry, t); + } + }, + ); + } else { + for (const entry of entries) { + for (const t of entry.transforms) { + deferToNodeImageService(entry, t); + } + } } - staticImages.set(entry.originalPath, { - originalSrcPath: entry.originalSrcPath, - transforms, - }); + + // Only load the Node-side image service if some transforms still need it. + if (staticImages.size > 0) { + globalThis.astroAsset ??= {}; + if (userImageServiceEntrypoint) { + const mod = await import(userImageServiceEntrypoint); + globalThis.astroAsset.imageService = mod.default ?? mod; + } else { + const { default: sharpService } = await import('astro/assets/services/sharp'); + globalThis.astroAsset.imageService = sharpService; + } + } + + return staticImages; } - return staticImages; - } - : undefined, + : undefined, async teardown() { if (previewServer) { diff --git a/packages/integrations/cloudflare/src/utils/handler.ts b/packages/integrations/cloudflare/src/utils/handler.ts index d78a5bfe4662..069d3560f30b 100644 --- a/packages/integrations/cloudflare/src/utils/handler.ts +++ b/packages/integrations/cloudflare/src/utils/handler.ts @@ -16,6 +16,8 @@ import { handlePrerenderRequest, isStaticImagesRequest, handleStaticImagesRequest, + isImageTransformRequest, + handleImageTransformRequest, installPrerenderErrorPropagation, } from './prerender.js'; import { @@ -66,6 +68,16 @@ export async function handle( if (isStaticImagesRequest(request)) { return handleStaticImagesRequest() as unknown as CfResponse; } + if (isImageTransformRequest(request)) { + const imagesBindingName = globalThis.__ASTRO_IMAGES_BINDING_NAME; + return handleImageTransformRequest(request, { + images: + compileImageConfig?.transformWithBinding && imagesBindingName + ? (env as Record)[imagesBindingName] + : undefined, + assets: compileImageConfig?.transformWithBinding ? env.ASSETS : undefined, + }) as unknown as CfResponse; + } } injectSessionBinding(app.manifest, env); diff --git a/packages/integrations/cloudflare/src/utils/image-binding-transform.ts b/packages/integrations/cloudflare/src/utils/image-binding-transform.ts index 05c19a69c510..5cfb8939d7d0 100644 --- a/packages/integrations/cloudflare/src/utils/image-binding-transform.ts +++ b/packages/integrations/cloudflare/src/utils/image-binding-transform.ts @@ -12,6 +12,50 @@ const qualityTable: Record = { max: 100, }; +/** + * Transforms an already-resolved image stream. Split out from `transform` so the build + * can hand over source bytes it read itself: during the build the original image lives + * in Astro's intermediate output rather than behind the ASSETS binding, so the worker + * has no way to fetch it. + */ +export async function transformStream( + body: ReadableStream, + params: URLSearchParams, + images: ImagesBinding, +): Promise { + const supportedFormats: Record = { + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + }; + + const outputFormat = supportedFormats[params.get('f') ?? '']; + + if (!outputFormat) { + return new Response(`Unsupported format: ${params.get('f')}`, { status: 400 }); + } + + return ( + await images + .input(body) + .transform({ + width: params.has('w') ? Number.parseInt(params.get('w')!) : undefined, + height: params.has('h') ? Number.parseInt(params.get('h')!) : undefined, + fit: params.get('fit') as ImageTransform['fit'], + }) + .output({ + quality: params.get('q') + ? (qualityTable[params.get('q') as ImageQualityPreset] ?? + Number.parseInt(params.get('q')!)) + : undefined, + format: outputFormat, + }) + ).response(); +} + export async function transform( rawUrl: string, images: ImagesBinding, @@ -49,36 +93,6 @@ export async function transform( if (!content.body) { return new Response(null, { status: 404 }); } - const input = images.input(content.body); - - const supportedFormats: Record = { - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - webp: 'image/webp', - avif: 'image/avif', - }; - - const outputFormat = supportedFormats[url.searchParams.get('f') ?? '']; - if (!outputFormat) { - return new Response(`Unsupported format: ${url.searchParams.get('f')}`, { status: 400 }); - } - - return ( - await input - .transform({ - width: url.searchParams.has('w') ? Number.parseInt(url.searchParams.get('w')!) : undefined, - height: url.searchParams.has('h') ? Number.parseInt(url.searchParams.get('h')!) : undefined, - fit: url.searchParams.get('fit') as ImageTransform['fit'], - }) - .output({ - quality: url.searchParams.get('q') - ? (qualityTable[url.searchParams.get('q') as ImageQualityPreset] ?? - Number.parseInt(url.searchParams.get('q')!)) - : undefined, - format: outputFormat, - }) - ).response(); + return transformStream(content.body, url.searchParams, images); } diff --git a/packages/integrations/cloudflare/src/utils/image-config.ts b/packages/integrations/cloudflare/src/utils/image-config.ts index e30ec1a9ed52..11806d15595c 100644 --- a/packages/integrations/cloudflare/src/utils/image-config.ts +++ b/packages/integrations/cloudflare/src/utils/image-config.ts @@ -13,12 +13,19 @@ export type ImageServiceConfig = | { build: 'compile'; runtime?: 'passthrough' | 'cloudflare-binding'; + } + | { + build: 'cloudflare-binding'; + runtime?: 'cloudflare-binding' | 'passthrough'; }; -/** Normalize string | compound config into separate build/runtime modes. */ +/** Normalize string | compound config into separate build/runtime modes. + * `transformAtBuild` is true when the compound config is used; this opts the user + * in to build-time image optimization. The string form preserves runtime-only behavior. */ export function normalizeImageServiceConfig(config: ImageServiceConfig | undefined): { buildService: ImageServiceMode; runtimeService: ImageServiceMode; + transformAtBuild: boolean; } { if (!config || typeof config === 'string') { const mode = config ?? 'cloudflare-binding'; @@ -26,12 +33,16 @@ export function normalizeImageServiceConfig(config: ImageServiceConfig | undefin return { buildService: mode, runtimeService: mode === 'compile' ? 'passthrough' : mode, + // Only `compile` opts in to build-time transforms via the string shorthand. + // String `cloudflare-binding` preserves the historical runtime-only behavior. + transformAtBuild: mode === 'compile', }; } - // Compound config: { build: 'compile', runtime?: ... } + // Compound config: user explicitly opts in to build-time transforms. return { - buildService: 'compile', - runtimeService: config.runtime ?? 'passthrough', + buildService: config.build, + runtimeService: config.runtime ?? (config.build === 'compile' ? 'passthrough' : config.build), + transformAtBuild: true, }; } @@ -93,12 +104,16 @@ export function setImageConfig( }; case 'cloudflare-binding': + // Dev always transforms through the IMAGES binding. At runtime, the compound + // config `{ build: 'cloudflare-binding', runtime: 'passthrough' }` serves + // original images instead of transforming on demand. return { ...config, service: WORKERD_IMAGE_SERVICE, - endpoint: { - entrypoint: '@astrojs/cloudflare/image-transform-endpoint', - }, + endpoint: + command === 'dev' || runtimeService === 'cloudflare-binding' + ? { entrypoint: '@astrojs/cloudflare/image-transform-endpoint' } + : CLOUDFLARE_PASSTHROUGH_ENDPOINT, }; case 'compile': { diff --git a/packages/integrations/cloudflare/src/utils/prerender-constants.ts b/packages/integrations/cloudflare/src/utils/prerender-constants.ts index d12557b49f20..1ede11f2b94b 100644 --- a/packages/integrations/cloudflare/src/utils/prerender-constants.ts +++ b/packages/integrations/cloudflare/src/utils/prerender-constants.ts @@ -10,3 +10,10 @@ export const PRERENDER_ENDPOINT = '/__astro_prerender'; /** Internal endpoint for fetching static images collected in workerd during `compile` builds */ export const STATIC_IMAGES_ENDPOINT = '/__astro_static_images'; + +/** + * Internal endpoint for transforming a single image with the IMAGES binding during + * `cloudflare-binding` builds. Takes the same query parameters as `/_image` and streams + * the optimized bytes back, one image per request. + */ +export const IMAGE_TRANSFORM_ENDPOINT = '/__astro_image_transform'; diff --git a/packages/integrations/cloudflare/src/utils/prerender.ts b/packages/integrations/cloudflare/src/utils/prerender.ts index a7ed842dee96..5ebad1ddf0ff 100644 --- a/packages/integrations/cloudflare/src/utils/prerender.ts +++ b/packages/integrations/cloudflare/src/utils/prerender.ts @@ -27,7 +27,12 @@ import { STATIC_PATHS_ENDPOINT, PRERENDER_ENDPOINT, STATIC_IMAGES_ENDPOINT, + IMAGE_TRANSFORM_ENDPOINT, } from './prerender-constants.js'; +import { + transform as transformWithImagesBinding, + transformStream as transformStreamWithImagesBinding, +} from './image-binding-transform.js'; /** * Replicates core's `BuildErrorHandler` semantics on the worker app during @@ -136,6 +141,11 @@ export function isStaticImagesRequest(request: Request): boolean { return pathname === STATIC_IMAGES_ENDPOINT && request.method === 'POST'; } +export function isImageTransformRequest(request: Request): boolean { + const { pathname } = new URL(request.url); + return pathname === IMAGE_TRANSFORM_ENDPOINT && request.method === 'POST'; +} + /** Serializes the global staticImages map collected in workerd back to the Node-side build. */ export function handleStaticImagesRequest(): Response { const staticImages = globalThis.astroAsset?.staticImages; @@ -162,3 +172,50 @@ export function handleStaticImagesRequest(): Response { headers: { 'Content-Type': 'application/json' }, }); } + +interface ImageTransformOptions { + /** The Cloudflare IMAGES binding for image transformation. */ + images?: ImagesBinding; + /** The Cloudflare ASSETS fetcher for loading local images. */ + assets?: Fetcher; +} + +/** + * Transforms a single image with the Cloudflare IMAGES binding and streams the raw + * bytes back to the Node-side build. + * + * The transform parameters arrive as query parameters on the request URL, in the same + * shape `/_image` uses. Handling one image per request keeps peak memory proportional to + * a single variant rather than to the whole image set, since neither the request body + * nor the response body is ever buffered in the isolate. + * + * Local originals are uploaded as the request body: at this point in the build they live + * in Astro's intermediate output, not in the client directory the ASSETS binding serves, + * so the worker cannot fetch them itself. Remote images have no body and are resolved + * here, exactly as the runtime `image-transform-endpoint` does. + */ +export async function handleImageTransformRequest( + request: Request, + { images, assets }: ImageTransformOptions, +): Promise { + if (!images) { + return new Response('The Cloudflare IMAGES binding is not available in the prerender worker.', { + status: 503, + }); + } + + if (request.body) { + return transformStreamWithImagesBinding( + request.body, + new URL(request.url).searchParams, + images, + ); + } + + if (!assets) { + return new Response('The Cloudflare ASSETS binding is not available in the prerender worker.', { + status: 503, + }); + } + return transformWithImagesBinding(request.url, images, assets); +} diff --git a/packages/integrations/cloudflare/src/vite-plugin-config.ts b/packages/integrations/cloudflare/src/vite-plugin-config.ts index 613c5725ecee..9b6f2d50e58b 100644 --- a/packages/integrations/cloudflare/src/vite-plugin-config.ts +++ b/packages/integrations/cloudflare/src/vite-plugin-config.ts @@ -8,6 +8,7 @@ export interface CompileImageConfig { assetsPrefix: string | undefined; imageServiceEntrypoint: string; buildAssets: string; + transformWithBinding: boolean; } export interface Config { diff --git a/packages/integrations/cloudflare/test/binding-build-image-service.test.ts b/packages/integrations/cloudflare/test/binding-build-image-service.test.ts new file mode 100644 index 000000000000..fb45aca47e7d --- /dev/null +++ b/packages/integrations/cloudflare/test/binding-build-image-service.test.ts @@ -0,0 +1,68 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.ts'; + +// `imageService: { build: 'cloudflare-binding' }` opts in to transforming static images +// with the IMAGES binding inside workerd during the build. Each optimized image is +// streamed out of the prerender worker on its own request, so nothing here depends on +// the whole image set fitting in memory. +// +// Both the binding and the Node-side fallback emit a valid WEBP, so the generated bytes +// alone cannot tell them apart. The adapter warns whenever it falls back, so this test +// raises the log level (fixtures build silently by default), captures stdout, and treats +// that warning as proof the streaming endpoint did not do its job. +describe('BindingBuildImageService build-time image generation', () => { + const FALLBACK_WARNING = 'Falling back to the local image service'; + + async function buildFixture(outDirName: string) { + const fixture = await loadFixture({ + root: './fixtures/compile-custom-image-service/', + outDir: `./dist/binding-build-image-service-${outDirName}/`, + }); + const resetConfig = await fixture.editFile( + 'astro.config.mjs', + (contents) => + contents.replace( + "imageService: 'compile'", + "imageService: { build: 'cloudflare-binding', runtime: 'cloudflare-binding' }", + ), + false, + ); + + // Astro's node logger resolves `process.stdout` per write, so patching it here + // intercepts the adapter's warnings. + const log: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: any, ...rest: any[]) => { + log.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return (originalWrite as any)(chunk, ...rest); + }) as typeof process.stdout.write; + + try { + await fixture.build({ logLevel: 'warn' }); + return { fixture, html: await fixture.readFile('client/index.html'), log: log.join('') }; + } finally { + process.stdout.write = originalWrite; + resetConfig(); + } + } + + it('streams IMAGES-binding output into the client directory instead of falling back', async () => { + const { fixture, html, log } = await buildFixture('binding'); + + // The binding handled every transform: nothing fell through to the Node side. + assert.ok( + !log.includes(FALLBACK_WARNING), + `expected the IMAGES binding to transform every image, but the build fell back:\n${log}`, + ); + + const src = cheerio.load(html)('img').attr('src'); + assert.match(src ?? '', /^\/_astro\/.+\.webp$/, 'expected a hashed .webp asset in the markup'); + + // The streamed bytes landed on disk as an intact WEBP container. + const data = (await fixture.readFile(`client${src}`, null)) as unknown as Buffer; + assert.equal(data.subarray(0, 4).toString('utf8'), 'RIFF'); + assert.equal(data.subarray(8, 12).toString('utf8'), 'WEBP'); + }); +}); diff --git a/packages/integrations/cloudflare/test/image-config.test.ts b/packages/integrations/cloudflare/test/image-config.test.ts new file mode 100644 index 000000000000..b84bbe3d8863 --- /dev/null +++ b/packages/integrations/cloudflare/test/image-config.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { normalizeImageServiceConfig } from '../dist/utils/image-config.js'; + +describe('normalizeImageServiceConfig', () => { + it('keeps the cloudflare-binding shorthand runtime-only', () => { + assert.deepEqual(normalizeImageServiceConfig('cloudflare-binding'), { + buildService: 'cloudflare-binding', + runtimeService: 'cloudflare-binding', + transformAtBuild: false, + }); + }); + + it('opts compound cloudflare-binding config into build-time transforms', () => { + assert.deepEqual( + normalizeImageServiceConfig({ + build: 'cloudflare-binding', + runtime: 'cloudflare-binding', + }), + { + buildService: 'cloudflare-binding', + runtimeService: 'cloudflare-binding', + transformAtBuild: true, + }, + ); + }); +});