From f0a0af3813fc0a692239041149c6fa63f58b7a87 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:56:12 +0200 Subject: [PATCH 1/3] feat(cloudflare): opt-in build-time image optimization via IMAGES binding Adds a compound imageService config { build: 'cloudflare-binding', runtime?: ... } that opts in to transforming static images with the Cloudflare IMAGES binding in the workerd prerender environment at build time, writing the optimized bytes directly to the output directory (falling back to Sharp when the binding fails). The string shorthand 'cloudflare-binding' keeps runtime-only behavior. Closes #16035 Co-authored-by: Daedalus <6442298+Daedalus-Icarus@users.noreply.github.com> --- ...x-cloudflare-binding-image-optimization.md | 17 +++ packages/integrations/cloudflare/src/index.ts | 20 ++-- .../cloudflare/src/prerender-types.ts | 2 + .../cloudflare/src/prerenderer.ts | 100 +++++++++++------- .../cloudflare/src/utils/handler.ts | 9 +- .../cloudflare/src/utils/image-config.ts | 29 +++-- .../cloudflare/src/utils/prerender.ts | 80 +++++++++++++- .../cloudflare/src/vite-plugin-config.ts | 1 + .../cloudflare/test/image-config.test.ts | 27 +++++ 9 files changed, 233 insertions(+), 52 deletions(-) create mode 100644 .changeset/fix-cloudflare-binding-image-optimization.md create mode 100644 packages/integrations/cloudflare/test/image-config.test.ts diff --git a/.changeset/fix-cloudflare-binding-image-optimization.md b/.changeset/fix-cloudflare-binding-image-optimization.md new file mode 100644 index 000000000000..d299e7b02cee --- /dev/null +++ b/.changeset/fix-cloudflare-binding-image-optimization.md @@ -0,0 +1,17 @@ +--- +'@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 (falling back to the Node-side image service, e.g. Sharp, if the binding fails). + +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 22705b7c223d..3e8049e2dc45 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -142,9 +142,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', @@ -183,10 +187,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'; @@ -212,7 +216,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 }, }), @@ -409,7 +415,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: @@ -418,6 +424,7 @@ export default function createIntegration({ : undefined, imageServiceEntrypoint: '@astrojs/cloudflare/image-service-workerd', buildAssets: config.build.assets ?? '_astro', + transformWithBinding: isBindingBuild, }) : null, cacheProviderEnabled: needsWorkerCache, @@ -508,6 +515,7 @@ export default function createIntegration({ trailingSlash: _config.trailingSlash, cfPluginConfig, hasBuildImageService, + hasBindingImageService: isBindingBuild, userImageServiceEntrypoint: hasUserBuildImageService ? resolveImageServiceEntrypoint(_config.image.service.entrypoint, _config.root) : undefined, diff --git a/packages/integrations/cloudflare/src/prerender-types.ts b/packages/integrations/cloudflare/src/prerender-types.ts index b58ae475394f..f77e0512e95b 100644 --- a/packages/integrations/cloudflare/src/prerender-types.ts +++ b/packages/integrations/cloudflare/src/prerender-types.ts @@ -30,6 +30,8 @@ export interface SerializedStaticImageEntry { hash: string; finalPath: string; transform: Record; + /** Base64-encoded transformed image data, present when the IMAGES binding was used at build time. */ + imageData?: string; }>; } diff --git a/packages/integrations/cloudflare/src/prerenderer.ts b/packages/integrations/cloudflare/src/prerenderer.ts index a3607f1b3d63..0428e5be5030 100644 --- a/packages/integrations/cloudflare/src/prerenderer.ts +++ b/packages/integrations/cloudflare/src/prerenderer.ts @@ -6,7 +6,8 @@ import type { } from 'astro'; import { preview, createLogger, type PreviewServer as VitePreviewServer } from 'vite'; import { fileURLToPath } from 'node:url'; -import { mkdir } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; import { cloudflare as cfVitePlugin, type PluginConfig } from '@cloudflare/vite-plugin'; import { serializeRouteData, deserializeRouteData } from 'astro/app/manifest'; import type { @@ -28,6 +29,8 @@ interface CloudflarePrerendererOptions { trailingSlash: AstroConfig['trailingSlash']; cfPluginConfig: PluginConfig; hasBuildImageService: boolean; + /** When true, images were pre-optimized by the IMAGES binding in workerd and can be written directly. */ + hasBindingImageService: boolean; userImageServiceEntrypoint?: string; } @@ -43,6 +46,7 @@ export function createCloudflarePrerenderer({ trailingSlash, cfPluginConfig, hasBuildImageService, + hasBindingImageService, userImageServiceEntrypoint, }: CloudflarePrerendererOptions): AstroPrerenderer { let previewServer: VitePreviewServer | undefined; @@ -148,46 +152,70 @@ 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(); + + // For transforms that already have imageData (optimized by the IMAGES binding + // in workerd), write the bytes directly to the client output directory. + // Remaining transforms without imageData fall through to the Node-side + // image service (the user-configured service or Sharp). + const staticImages: AssetsGlobalStaticImagesList = new Map(); + let needsNodeImageService = false; + + for (const entry of entries) { + const transforms = new Map(); + for (const t of entry.transforms) { + if (t.imageData) { + // Image was already transformed by the Cloudflare IMAGES binding; + // write it directly to the output directory. + const outputPath = join(fileURLToPath(clientDir), t.finalPath); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, Buffer.from(t.imageData, 'base64')); + } else { + // No pre-transformed data; collect for Node-side processing. + transforms.set(t.hash, { finalPath: t.finalPath, transform: t.transform }); + needsNodeImageService = true; + } + } + if (transforms.size > 0) { + staticImages.set(entry.originalPath, { + originalSrcPath: entry.originalSrcPath, + transforms, + }); + } + } - 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 }); + // Only load the Node-side image service if some transforms weren't + // handled by the binding. + if (needsNodeImageService) { + 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; + } } - staticImages.set(entry.originalPath, { - originalSrcPath: entry.originalSrcPath, - transforms, - }); + + 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..aaef49965d01 100644 --- a/packages/integrations/cloudflare/src/utils/handler.ts +++ b/packages/integrations/cloudflare/src/utils/handler.ts @@ -64,7 +64,14 @@ export async function handle( return handlePrerenderRequest(app, request) as unknown as CfResponse; } if (isStaticImagesRequest(request)) { - return handleStaticImagesRequest() as unknown as CfResponse; + const imagesBindingName = globalThis.__ASTRO_IMAGES_BINDING_NAME; + return handleStaticImagesRequest({ + images: + compileImageConfig?.transformWithBinding && imagesBindingName + ? (env as Record)[imagesBindingName] + : undefined, + assets: compileImageConfig?.transformWithBinding ? env.ASSETS : undefined, + }) as unknown as CfResponse; } } 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.ts b/packages/integrations/cloudflare/src/utils/prerender.ts index 61f83df547ec..08b649dfb2f8 100644 --- a/packages/integrations/cloudflare/src/utils/prerender.ts +++ b/packages/integrations/cloudflare/src/utils/prerender.ts @@ -28,6 +28,7 @@ import { PRERENDER_ENDPOINT, STATIC_IMAGES_ENDPOINT, } from './prerender-constants.js'; +import { transform as transformWithImagesBinding } from './image-binding-transform.js'; /** * Replicates core's `BuildErrorHandler` semantics on the worker app during @@ -133,8 +134,17 @@ export function isStaticImagesRequest(request: Request): boolean { return pathname === STATIC_IMAGES_ENDPOINT && request.method === 'POST'; } -/** Serializes the global staticImages map collected in workerd back to the Node-side build. */ -export function handleStaticImagesRequest(): Response { +interface StaticImagesOptions { + /** The Cloudflare IMAGES binding for image transformation. */ + images?: ImagesBinding; + /** The Cloudflare ASSETS fetcher for loading local images. */ + assets?: Fetcher; +} + +/** Serializes the global staticImages map collected in workerd back to the Node-side build. + * When IMAGES and ASSETS bindings are provided, transforms images using the Cloudflare + * binding and includes the optimized bytes in the response. */ +export async function handleStaticImagesRequest(options?: StaticImagesOptions): Promise { const staticImages = globalThis.astroAsset?.staticImages; if (!staticImages || staticImages.size === 0) { return new Response('[]', { @@ -142,14 +152,29 @@ export function handleStaticImagesRequest(): Response { }); } + const { images, assets } = options ?? {}; + const canTransform = !!images && !!assets; + const entries: StaticImagesResponse = []; for (const [originalPath, { originalSrcPath, transforms }] of staticImages) { const serializedTransforms: SerializedStaticImageEntry['transforms'] = []; for (const [hash, { finalPath, transform }] of transforms) { + let imageData: string | undefined; + + if (canTransform) { + try { + imageData = await transformWithBinding(originalPath, transform, images, assets); + } catch { + // If the IMAGES binding fails, fall back to metadata-only + // so the Node side can use Sharp as a fallback. + } + } + serializedTransforms.push({ hash, finalPath, transform: transform as Record, + imageData, }); } entries.push({ originalPath, originalSrcPath, transforms: serializedTransforms }); @@ -159,3 +184,54 @@ export function handleStaticImagesRequest(): Response { headers: { 'Content-Type': 'application/json' }, }); } + +/** Transforms a single image using the Cloudflare IMAGES binding and returns base64-encoded data. */ +async function transformWithBinding( + originalPath: string, + transform: Record, + images: ImagesBinding, + assets: Fetcher, +): Promise { + const response = await transformWithImagesBinding( + createImageTransformUrl(originalPath, transform), + images, + assets, + ); + if (!response.ok) { + throw new Error(`Failed to transform image: ${originalPath}`); + } + + const buffer = await response.arrayBuffer(); + + // Encode as base64 for JSON transport + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function createImageTransformUrl(originalPath: string, transform: Record): string { + const url = new URL('/_image', 'https://placeholder.host'); + url.searchParams.set('href', originalPath); + + const params: Record = { + w: 'width', + h: 'height', + q: 'quality', + f: 'format', + fit: 'fit', + position: 'position', + background: 'background', + }; + + for (const [param, key] of Object.entries(params)) { + const value = transform[key]; + if (value) { + url.searchParams.set(param, value.toString()); + } + } + + return url.toString(); +} 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/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, + }, + ); + }); +}); From b8e71eed5ce5f548845162b3502be601e7839340 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:34:26 +0200 Subject: [PATCH 2/3] fix(cloudflare): stream build-time image transforms instead of batching them The build path base64-encoded every prerendered transform into a single JSON response. Peak memory held the whole optimized image set several times over: once as base64 in the entries array, once in the `JSON.stringify` result, once as the encoded response body, then again on the Node side to parse. A site with a few hundred variants could exceed the isolate memory limit, and a large enough one hits V8's max string length in `JSON.stringify`. Transform one image per request instead, mirroring the runtime `image-transform-endpoint`: `/__astro_image_transform` streams the optimized bytes straight from the IMAGES binding into the client output directory, with bounded concurrency. Peak memory is now proportional to the concurrency limit rather than to the image set. This also fixes the binding never actually running. The worker resolved the original through the ASSETS binding, but at that point in the build the unoptimized original lives in Astro's prerender output, not in the client directory ASSETS serves, so every transform failed with an empty input. The failure was swallowed by a bare `catch {}` and silently fell back to Sharp, so builds looked successful while the binding did nothing. The original is now streamed up as the request body, and failures are logged instead of hidden. Adds a test that builds with the binding config and fails if any image falls back to the Node-side service. --- packages/integrations/cloudflare/src/index.ts | 3 +- .../cloudflare/src/prerender-types.ts | 2 - .../cloudflare/src/prerenderer.ts | 216 +++++++++++++++--- .../cloudflare/src/utils/handler.ts | 7 +- .../src/utils/image-binding-transform.ts | 76 +++--- .../src/utils/prerender-constants.ts | 7 + .../cloudflare/src/utils/prerender.ts | 119 ++++------ .../test/binding-build-image-service.test.ts | 68 ++++++ 8 files changed, 364 insertions(+), 134 deletions(-) create mode 100644 packages/integrations/cloudflare/test/binding-build-image-service.test.ts diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 3e8049e2dc45..c47683981e6f 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -504,7 +504,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({ @@ -519,6 +519,7 @@ export default function createIntegration({ userImageServiceEntrypoint: hasUserBuildImageService ? resolveImageServiceEntrypoint(_config.image.service.entrypoint, _config.root) : undefined, + logger, }), ); } diff --git a/packages/integrations/cloudflare/src/prerender-types.ts b/packages/integrations/cloudflare/src/prerender-types.ts index f77e0512e95b..b58ae475394f 100644 --- a/packages/integrations/cloudflare/src/prerender-types.ts +++ b/packages/integrations/cloudflare/src/prerender-types.ts @@ -30,8 +30,6 @@ export interface SerializedStaticImageEntry { hash: string; finalPath: string; transform: Record; - /** Base64-encoded transformed image data, present when the IMAGES binding was used at build time. */ - imageData?: string; }>; } diff --git a/packages/integrations/cloudflare/src/prerenderer.ts b/packages/integrations/cloudflare/src/prerenderer.ts index 0428e5be5030..6093ba945b4d 100644 --- a/packages/integrations/cloudflare/src/prerenderer.ts +++ b/packages/integrations/cloudflare/src/prerenderer.ts @@ -1,26 +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 { mkdir, writeFile } from 'node:fs/promises'; +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']; @@ -29,9 +56,105 @@ interface CloudflarePrerendererOptions { trailingSlash: AstroConfig['trailingSlash']; cfPluginConfig: PluginConfig; hasBuildImageService: boolean; - /** When true, images were pre-optimized by the IMAGES binding in workerd and can be written directly. */ + /** 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)); } /** @@ -48,6 +171,7 @@ export function createCloudflarePrerenderer({ hasBuildImageService, hasBindingImageService, userImageServiceEntrypoint, + logger, }: CloudflarePrerendererOptions): AstroPrerenderer { let previewServer: VitePreviewServer | undefined; let serverUrl: string; @@ -170,39 +294,71 @@ export function createCloudflarePrerenderer({ const entries: StaticImagesResponse = await response.json(); - // For transforms that already have imageData (optimized by the IMAGES binding - // in workerd), write the bytes directly to the client output directory. - // Remaining transforms without imageData fall through to the Node-side - // image service (the user-configured service or Sharp). + // Transforms left in this map fall through to the Node-side image + // service (the user-configured service, or Sharp). const staticImages: AssetsGlobalStaticImagesList = new Map(); - let needsNodeImageService = false; - - for (const entry of entries) { - const transforms = new Map(); - for (const t of entry.transforms) { - if (t.imageData) { - // Image was already transformed by the Cloudflare IMAGES binding; - // write it directly to the output directory. - const outputPath = join(fileURLToPath(clientDir), t.finalPath); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, Buffer.from(t.imageData, 'base64')); - } else { - // No pre-transformed data; collect for Node-side processing. - transforms.set(t.hash, { finalPath: t.finalPath, transform: t.transform }); - needsNodeImageService = true; - } + 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); } - if (transforms.size > 0) { - staticImages.set(entry.originalPath, { - originalSrcPath: entry.originalSrcPath, - transforms, - }); + existing.transforms.set(t.hash, { + finalPath: t.finalPath, + // Serialized over HTTP, so it arrives as a plain object. + transform: t.transform as ImageTransform, + }); + }; + + 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); + } } } - // Only load the Node-side image service if some transforms weren't - // handled by the binding. - if (needsNodeImageService) { + // 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); diff --git a/packages/integrations/cloudflare/src/utils/handler.ts b/packages/integrations/cloudflare/src/utils/handler.ts index aaef49965d01..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 { @@ -64,8 +66,11 @@ export async function handle( return handlePrerenderRequest(app, request) as unknown as CfResponse; } if (isStaticImagesRequest(request)) { + return handleStaticImagesRequest() as unknown as CfResponse; + } + if (isImageTransformRequest(request)) { const imagesBindingName = globalThis.__ASTRO_IMAGES_BINDING_NAME; - return handleStaticImagesRequest({ + return handleImageTransformRequest(request, { images: compileImageConfig?.transformWithBinding && imagesBindingName ? (env as Record)[imagesBindingName] 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/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 08b649dfb2f8..3c6253755ca4 100644 --- a/packages/integrations/cloudflare/src/utils/prerender.ts +++ b/packages/integrations/cloudflare/src/utils/prerender.ts @@ -27,8 +27,12 @@ import { STATIC_PATHS_ENDPOINT, PRERENDER_ENDPOINT, STATIC_IMAGES_ENDPOINT, + IMAGE_TRANSFORM_ENDPOINT, } from './prerender-constants.js'; -import { transform as transformWithImagesBinding } from './image-binding-transform.js'; +import { + transform as transformWithImagesBinding, + transformStream as transformStreamWithImagesBinding, +} from './image-binding-transform.js'; /** * Replicates core's `BuildErrorHandler` semantics on the worker app during @@ -134,17 +138,13 @@ export function isStaticImagesRequest(request: Request): boolean { return pathname === STATIC_IMAGES_ENDPOINT && request.method === 'POST'; } -interface StaticImagesOptions { - /** The Cloudflare IMAGES binding for image transformation. */ - images?: ImagesBinding; - /** The Cloudflare ASSETS fetcher for loading local images. */ - assets?: Fetcher; +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. - * When IMAGES and ASSETS bindings are provided, transforms images using the Cloudflare - * binding and includes the optimized bytes in the response. */ -export async function handleStaticImagesRequest(options?: StaticImagesOptions): Promise { +/** Serializes the global staticImages map collected in workerd back to the Node-side build. */ +export function handleStaticImagesRequest(): Response { const staticImages = globalThis.astroAsset?.staticImages; if (!staticImages || staticImages.size === 0) { return new Response('[]', { @@ -152,29 +152,14 @@ export async function handleStaticImagesRequest(options?: StaticImagesOptions): }); } - const { images, assets } = options ?? {}; - const canTransform = !!images && !!assets; - const entries: StaticImagesResponse = []; for (const [originalPath, { originalSrcPath, transforms }] of staticImages) { const serializedTransforms: SerializedStaticImageEntry['transforms'] = []; for (const [hash, { finalPath, transform }] of transforms) { - let imageData: string | undefined; - - if (canTransform) { - try { - imageData = await transformWithBinding(originalPath, transform, images, assets); - } catch { - // If the IMAGES binding fails, fall back to metadata-only - // so the Node side can use Sharp as a fallback. - } - } - serializedTransforms.push({ hash, finalPath, transform: transform as Record, - imageData, }); } entries.push({ originalPath, originalSrcPath, transforms: serializedTransforms }); @@ -185,53 +170,49 @@ export async function handleStaticImagesRequest(options?: StaticImagesOptions): }); } -/** Transforms a single image using the Cloudflare IMAGES binding and returns base64-encoded data. */ -async function transformWithBinding( - originalPath: string, - transform: Record, - images: ImagesBinding, - assets: Fetcher, -): Promise { - const response = await transformWithImagesBinding( - createImageTransformUrl(originalPath, transform), - images, - assets, - ); - if (!response.ok) { - throw new Error(`Failed to transform image: ${originalPath}`); - } - - const buffer = await response.arrayBuffer(); - - // Encode as base64 for JSON transport - const bytes = new Uint8Array(buffer); - let binary = ''; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary); +interface ImageTransformOptions { + /** The Cloudflare IMAGES binding for image transformation. */ + images?: ImagesBinding; + /** The Cloudflare ASSETS fetcher for loading local images. */ + assets?: Fetcher; } -function createImageTransformUrl(originalPath: string, transform: Record): string { - const url = new URL('/_image', 'https://placeholder.host'); - url.searchParams.set('href', originalPath); - - const params: Record = { - w: 'width', - h: 'height', - q: 'quality', - f: 'format', - fit: 'fit', - position: 'position', - background: 'background', - }; +/** + * 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, + }); + } - for (const [param, key] of Object.entries(params)) { - const value = transform[key]; - if (value) { - url.searchParams.set(param, value.toString()); - } + if (request.body) { + return transformStreamWithImagesBinding( + request.body, + new URL(request.url).searchParams, + images, + ); } - return url.toString(); + 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/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'); + }); +}); From f7a861f6fe9555248c4194b33e2b2316d6799943 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:45:13 +0200 Subject: [PATCH 3/3] Update .changeset/fix-cloudflare-binding-image-optimization.md Co-authored-by: Armand Philippot --- .changeset/fix-cloudflare-binding-image-optimization.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-cloudflare-binding-image-optimization.md b/.changeset/fix-cloudflare-binding-image-optimization.md index d299e7b02cee..45552612b6c9 100644 --- a/.changeset/fix-cloudflare-binding-image-optimization.md +++ b/.changeset/fix-cloudflare-binding-image-optimization.md @@ -2,7 +2,9 @@ '@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 (falling back to the Node-side image service, e.g. Sharp, if the binding fails). +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: