From e105cca7a53f5096aee0ec85b651c7785b84807a Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Tue, 16 Jun 2026 14:30:05 -0400 Subject: [PATCH 1/5] fix(cloudflare): respect custom `image.service` in compile mode --- ...loudflare-compile-respect-image-service.md | 5 +++ packages/integrations/cloudflare/src/index.ts | 17 +++++++++- .../cloudflare/src/prerenderer.ts | 12 +++++-- .../cloudflare/src/utils/image-config.ts | 24 +++++++++----- .../test/compile-image-service.test.ts | 31 ++++++++++++++++++ .../astro.config.mjs | 17 ++++++++++ .../compile-custom-image-service/package.json | 10 ++++++ .../src/assets/test.jpg | Bin 0 -> 38690 bytes .../src/image-service.ts | 23 +++++++++++++ .../src/pages/api.ts | 3 ++ .../src/pages/index.astro | 14 ++++++++ pnpm-lock.yaml | 9 +++++ 12 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 .changeset/cloudflare-compile-respect-image-service.md create mode 100644 packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs create mode 100644 packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/package.json create mode 100644 packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/assets/test.jpg create mode 100644 packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/image-service.ts create mode 100644 packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/pages/api.ts create mode 100644 packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/pages/index.astro diff --git a/.changeset/cloudflare-compile-respect-image-service.md b/.changeset/cloudflare-compile-respect-image-service.md new file mode 100644 index 000000000000..54695659b5fb --- /dev/null +++ b/.changeset/cloudflare-compile-respect-image-service.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +--- + +Respect custom `image.service` configuration for Cloudflare compile-time image generation diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 0542b0b9af6d..7bb3dd41936e 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -16,6 +16,7 @@ import { getParts } from './utils/generate-routes-json.js'; import { buildAssetsHeadersContent } from './utils/headers.js'; import { type ImageServiceConfig, + hasUserImageService, normalizeImageServiceConfig, setImageConfig, } from './utils/image-config.js'; @@ -74,6 +75,13 @@ function hasContentCollectionsConfig(srcDir: URL) { return contentConfigPaths.some((configPath) => existsSync(new URL(`./${configPath}`, srcDir))); } +function resolveImageServiceEntrypoint(entrypoint: string, root: URL): string { + if (entrypoint.startsWith('.')) { + return new URL(entrypoint, root).href; + } + return entrypoint; +} + export interface Options extends Pick< PluginConfig, @@ -131,6 +139,7 @@ export default function createIntegration({ let _routes: IntegrationResolvedRoute[]; let cfPluginConfig: PluginConfig; + let hasCustomCompileImageService = false; const { buildService, runtimeService } = normalizeImageServiceConfig(imageService); const needsImagesBinding = runtimeService === 'cloudflare-binding'; @@ -145,6 +154,7 @@ export default function createIntegration({ let session = config.session; const isCompile = buildService === 'compile'; + hasCustomCompileImageService = isCompile && hasUserImageService(config.image); if (needsImagesBinding) { logger.info( @@ -376,7 +386,9 @@ export default function createIntegration({ typeof config.build.assetsPrefix === 'string' ? config.build.assetsPrefix : undefined, - imageServiceEntrypoint: '@astrojs/cloudflare/image-service-workerd', + imageServiceEntrypoint: hasCustomCompileImageService + ? config.image.service.entrypoint + : '@astrojs/cloudflare/image-service-workerd', buildAssets: config.build.assets ?? '_astro', } : null, @@ -468,6 +480,9 @@ export default function createIntegration({ trailingSlash: _config.trailingSlash, cfPluginConfig, hasCompileImageService: buildService === 'compile', + userImageServiceEntrypoint: hasCustomCompileImageService + ? resolveImageServiceEntrypoint(_config.image.service.entrypoint, _config.root) + : undefined, }), ); } diff --git a/packages/integrations/cloudflare/src/prerenderer.ts b/packages/integrations/cloudflare/src/prerenderer.ts index f95c38eb4b2d..bff178c88679 100644 --- a/packages/integrations/cloudflare/src/prerenderer.ts +++ b/packages/integrations/cloudflare/src/prerenderer.ts @@ -28,6 +28,7 @@ interface CloudflarePrerendererOptions { trailingSlash: AstroConfig['trailingSlash']; cfPluginConfig: PluginConfig; hasCompileImageService: boolean; + userImageServiceEntrypoint?: string; } /** @@ -42,6 +43,7 @@ export function createCloudflarePrerenderer({ trailingSlash, cfPluginConfig, hasCompileImageService, + userImageServiceEntrypoint, }: CloudflarePrerendererOptions): AstroPrerenderer { let previewServer: VitePreviewServer | undefined; let serverUrl: string; @@ -154,10 +156,14 @@ export function createCloudflarePrerenderer({ const entries: StaticImagesResponse = await response.json(); - // Switch from the workerd stub to Sharp for the Node-side generation pipeline - const { default: sharpService } = await import('astro/assets/services/sharp'); globalThis.astroAsset ??= {}; - globalThis.astroAsset.imageService = sharpService; + 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 staticImages: AssetsGlobalStaticImagesList = new Map(); for (const entry of entries) { diff --git a/packages/integrations/cloudflare/src/utils/image-config.ts b/packages/integrations/cloudflare/src/utils/image-config.ts index 473bd6993718..c24c1d783600 100644 --- a/packages/integrations/cloudflare/src/utils/image-config.ts +++ b/packages/integrations/cloudflare/src/utils/image-config.ts @@ -48,6 +48,12 @@ const CLOUDFLARE_PASSTHROUGH_ENDPOINT = { // Used by both `compile` and `cloudflare-binding` for URL generation in workerd. const WORKERD_IMAGE_SERVICE = { entrypoint: '@astrojs/cloudflare/image-service-workerd' }; +const SHARP_IMAGE_SERVICE = 'astro/assets/services/sharp'; + +export function hasUserImageService(config: AstroConfig['image']): boolean { + return !!config.service?.entrypoint && config.service.entrypoint !== SHARP_IMAGE_SERVICE; +} + export function setImageConfig( service: ImageServiceConfig | undefined, config: AstroConfig['image'], @@ -89,17 +95,19 @@ export function setImageConfig( }, }; - case 'compile': + case 'compile': { + // Dev: IMAGES binding (via Cloudflare Vite plugin) for real transforms. + // Build: endpoint depends on runtime - `cloudflare-binding` uses IMAGES, `passthrough` uses generic. + const endpoint = + command === 'dev' || runtimeService === 'cloudflare-binding' + ? { entrypoint: '@astrojs/cloudflare/image-transform-endpoint' } + : CLOUDFLARE_PASSTHROUGH_ENDPOINT; return { ...config, - service: WORKERD_IMAGE_SERVICE, - // Dev: IMAGES binding (via Cloudflare Vite plugin) for real transforms. - // Build: endpoint depends on runtime - `cloudflare-binding` uses IMAGES, `passthrough` uses generic. - endpoint: - command === 'dev' || runtimeService === 'cloudflare-binding' - ? { entrypoint: '@astrojs/cloudflare/image-transform-endpoint' } - : CLOUDFLARE_PASSTHROUGH_ENDPOINT, + service: hasUserImageService(config) ? config.service : WORKERD_IMAGE_SERVICE, + endpoint, }; + } case 'custom': return { ...config }; diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts index 62652020a73f..c386c7a7bf29 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -79,3 +79,34 @@ describe('CompileImageService', () => { }); }); }); + +describe('CompileImageService custom service', () => { + let fixture: Fixture; + let html: string; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/compile-custom-image-service/', + outDir: './dist/compile-custom-image-service/', + }); + await fixture.build(); + html = await fixture.readFile('client/index.html'); + }); + + it('uses the custom service for markup', () => { + const $ = cheerio.load(html); + const img = $('img'); + + assert.equal(img.attr('data-image-service'), 'custom'); + assert.match(img.attr('src') ?? '', /^\/_astro\/.+\.webp$/); + }); + + it('uses the custom service for generated images', async () => { + const $ = cheerio.load(html); + const src = $('img').attr('src'); + assert.ok(src); + + const data = (await fixture.readFile(`client${src}`, null)) as unknown as Buffer; + assert.equal(Buffer.from(data.subarray(0, 20)).toString('utf8'), 'CUSTOM_TRANSFORM_RAN'); + }); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs new file mode 100644 index 000000000000..7915b95ff56b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs @@ -0,0 +1,17 @@ +import cloudflare from '@astrojs/cloudflare'; +import { defineConfig } from 'astro/config'; + +// `imageService: 'compile'` with a user-defined `image.service`. The adapter +// should preserve the custom service for getURL/getHTMLAttributes AND invoke its +// transform() during the build-time generation pass (instead of hardcoding sharp). +export default defineConfig({ + adapter: cloudflare({ + imageService: 'compile', + }), + output: 'static', + image: { + service: { + entrypoint: './src/image-service.ts', + }, + }, +}); diff --git a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/package.json b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/package.json new file mode 100644 index 000000000000..e210a2e28473 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/astro-cloudflare-compile-custom-image-service", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/assets/test.jpg b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/assets/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f4fc88e293c2d70d1b193101df252bf45ca57760 GIT binary patch literal 38690 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 T|DXOxj{SQDa(JEk*X+Lm3zv-d literal 0 HcmV?d00001 diff --git a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/image-service.ts b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/image-service.ts new file mode 100644 index 000000000000..097e7f4adbd3 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/image-service.ts @@ -0,0 +1,23 @@ +import type { LocalImageService } from 'astro'; +import { baseService } from 'astro/assets'; + +export const TRANSFORM_MARKER = 'CUSTOM_TRANSFORM_RAN'; + +const service: LocalImageService = { + ...baseService, + + getHTMLAttributes(options, config) { + const attrs = baseService.getHTMLAttributes?.(options, config) ?? {}; + return { ...attrs, 'data-image-service': 'custom' }; + }, + + async transform(inputBuffer, transformOptions, config) { + const marker = new TextEncoder().encode(`${TRANSFORM_MARKER}\n`); + const data = new Uint8Array(marker.length + inputBuffer.length); + data.set(marker, 0); + data.set(inputBuffer, marker.length); + return { data, format: transformOptions.format ?? 'png' }; + }, +}; + +export default service; diff --git a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/pages/api.ts b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/pages/api.ts new file mode 100644 index 000000000000..63824b1089f3 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/pages/api.ts @@ -0,0 +1,3 @@ +export const prerender = false; + +export const GET = () => new Response('ok'); diff --git a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/pages/index.astro new file mode 100644 index 000000000000..ee59594f2933 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/pages/index.astro @@ -0,0 +1,14 @@ +--- +import { Image } from 'astro:assets'; +import testImage from '../assets/test.jpg'; +--- + + + + + compile-custom-image-service + + + test + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d40fd3e5f533..43f25fa7bc15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4318,6 +4318,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/compile-custom-image-service: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/compile-image-service: dependencies: '@astrojs/cloudflare': From 64331a3b08d8a28bc19d82203bbbdada5ae5d582 Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Wed, 17 Jun 2026 10:28:03 -0400 Subject: [PATCH 2/5] Update .changeset/cloudflare-compile-respect-image-service.md Co-authored-by: Emanuele Stoppa --- .changeset/cloudflare-compile-respect-image-service.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cloudflare-compile-respect-image-service.md b/.changeset/cloudflare-compile-respect-image-service.md index 54695659b5fb..377effca85cc 100644 --- a/.changeset/cloudflare-compile-respect-image-service.md +++ b/.changeset/cloudflare-compile-respect-image-service.md @@ -2,4 +2,4 @@ '@astrojs/cloudflare': patch --- -Respect custom `image.service` configuration for Cloudflare compile-time image generation +Fixes an issue where the custom `image.service` wasn't respected for compile-time image generation. From f851fb9dbbe6286a8fb2abb1edd023f31ecab56a Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Wed, 17 Jun 2026 22:32:22 -0400 Subject: [PATCH 3/5] fix(cloudflare): also generate build-time images for `imageService: 'custom'` --- ...loudflare-compile-respect-image-service.md | 2 +- packages/integrations/cloudflare/src/index.ts | 15 +- .../cloudflare/src/prerenderer.ts | 6 +- .../test/compile-image-service.test.ts | 190 +++++++++++++++--- .../astro.config.mjs | 5 +- .../src/sharp-image-service.ts | 13 ++ 6 files changed, 190 insertions(+), 41 deletions(-) create mode 100644 packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/sharp-image-service.ts diff --git a/.changeset/cloudflare-compile-respect-image-service.md b/.changeset/cloudflare-compile-respect-image-service.md index 377effca85cc..01a9deaa605d 100644 --- a/.changeset/cloudflare-compile-respect-image-service.md +++ b/.changeset/cloudflare-compile-respect-image-service.md @@ -2,4 +2,4 @@ '@astrojs/cloudflare': patch --- -Fixes an issue where the custom `image.service` wasn't respected for compile-time image generation. +Fixes build-time image generation in the Cloudflare adapter so a custom `image.service` is respected in `imageService: 'compile'` mode, and extends `imageService: 'custom'` to also generate optimized image assets at build time. diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 7bb3dd41936e..dce0d9a3d1d9 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -139,10 +139,11 @@ export default function createIntegration({ let _routes: IntegrationResolvedRoute[]; let cfPluginConfig: PluginConfig; - let hasCustomCompileImageService = false; + let hasUserBuildImageService = false; const { buildService, runtimeService } = normalizeImageServiceConfig(imageService); const needsImagesBinding = runtimeService === 'cloudflare-binding'; + const hasBuildImageService = buildService === 'compile' || buildService === 'custom'; return { name: '@astrojs/cloudflare', @@ -154,13 +155,13 @@ export default function createIntegration({ let session = config.session; const isCompile = buildService === 'compile'; - hasCustomCompileImageService = isCompile && hasUserImageService(config.image); + hasUserBuildImageService = hasBuildImageService && hasUserImageService(config.image); if (needsImagesBinding) { logger.info( `Enabling image processing with Cloudflare Images for production with the "${imagesBindingName}" Images binding.`, ); - } else if (isCompile) { + } else if (hasBuildImageService) { logger.info( `Enabling compile-time image optimization. Images will be pre-optimized at build time.`, ); @@ -379,14 +380,14 @@ export default function createIntegration({ createConfigPlugin({ sessionKVBindingName, compileImageConfig: - isCompile && command !== 'dev' + hasBuildImageService && command !== 'dev' ? { base: config.base, assetsPrefix: typeof config.build.assetsPrefix === 'string' ? config.build.assetsPrefix : undefined, - imageServiceEntrypoint: hasCustomCompileImageService + imageServiceEntrypoint: hasUserBuildImageService ? config.image.service.entrypoint : '@astrojs/cloudflare/image-service-workerd', buildAssets: config.build.assets ?? '_astro', @@ -479,8 +480,8 @@ export default function createIntegration({ base: _config.base, trailingSlash: _config.trailingSlash, cfPluginConfig, - hasCompileImageService: buildService === 'compile', - userImageServiceEntrypoint: hasCustomCompileImageService + hasBuildImageService, + userImageServiceEntrypoint: hasUserBuildImageService ? resolveImageServiceEntrypoint(_config.image.service.entrypoint, _config.root) : undefined, }), diff --git a/packages/integrations/cloudflare/src/prerenderer.ts b/packages/integrations/cloudflare/src/prerenderer.ts index bff178c88679..a69367428fcc 100644 --- a/packages/integrations/cloudflare/src/prerenderer.ts +++ b/packages/integrations/cloudflare/src/prerenderer.ts @@ -27,7 +27,7 @@ interface CloudflarePrerendererOptions { base: AstroConfig['base']; trailingSlash: AstroConfig['trailingSlash']; cfPluginConfig: PluginConfig; - hasCompileImageService: boolean; + hasBuildImageService: boolean; userImageServiceEntrypoint?: string; } @@ -42,7 +42,7 @@ export function createCloudflarePrerenderer({ base, trailingSlash, cfPluginConfig, - hasCompileImageService, + hasBuildImageService, userImageServiceEntrypoint, }: CloudflarePrerendererOptions): AstroPrerenderer { let previewServer: VitePreviewServer | undefined; @@ -139,7 +139,7 @@ export function createCloudflarePrerenderer({ return response; }, - collectStaticImages: hasCompileImageService + collectStaticImages: hasBuildImageService ? async (): Promise => { const response = await fetch(`${serverUrl}${STATIC_IMAGES_ENDPOINT}`, { method: 'POST', diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts index c386c7a7bf29..44db423aeb3c 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -80,33 +80,169 @@ describe('CompileImageService', () => { }); }); -describe('CompileImageService custom service', () => { - let fixture: Fixture; - let html: string; - - before(async () => { - fixture = await loadFixture({ +// Both `imageService: 'compile'` and `imageService: 'custom'` opt in to build-time +// asset generation. +// +// | imageService | user image.service | build assets | worker bundle | +// | ------------ | -------------------- | ------------ | ------------------------- | +// | 'compile' | none (default Sharp) | real WEBP | clean (no Sharp) | +// | 'compile' | custom, Sharp-free | CUSTOM_* | user service, no Sharp | +// | 'compile' | custom, Sharp-backed | real WEBP | Sharp chain bundled | +// | 'custom' | none (default Sharp) | real WEBP | Sharp dragged in (beware) | +// | 'custom' | custom, Sharp-free | CUSTOM_* | user service, no Sharp | +// | 'custom' | custom, Sharp-backed | real WEBP | Sharp chain bundled | +describe('CompileImageService build-time image generation', () => { + async function readServerBundle(fixture: Fixture) { + const serverFiles = await fixture.glob('server/**/*.mjs'); + const contents = await Promise.all( + serverFiles.map(async (file) => await fixture.readFile(file)), + ); + + return contents.join('\n'); + } + + function assertSharpBundled(serverBundle: string) { + assert.match(serverBundle, /import\("sharp"\)/, 'expected the worker bundle to import "sharp"'); + assert.match( + serverBundle, + /assets\/services\/sharp/, + "expected Astro's Sharp service in the worker bundle", + ); + } + + function assertSharpNotBundled(serverBundle: string) { + assert.doesNotMatch( + serverBundle, + /import\("sharp"\)/, + 'expected the worker bundle to be free of "sharp"', + ); + assert.doesNotMatch( + serverBundle, + /assets\/services\/sharp/, + 'expected no Astro Sharp service in the worker bundle', + ); + } + + function assertRealWebp(data: Buffer) { + assert.equal(data.subarray(0, 4).toString('utf8'), 'RIFF'); + assert.equal(data.subarray(8, 12).toString('utf8'), 'WEBP'); + } + + /** + * Builds the `compile-custom-image-service` fixture, rewriting its config for + * the requested build mode and image service before the build and restoring it + * afterwards. + * + * @param mode `'compile'` or `'custom'`. + * @param service `'default'` removes the user `image.service` (Astro's default + * Sharp service applies), `'sharp'` swaps in a Sharp-backed user + * service, and `'user'` keeps the fixture's Sharp-free service. + */ + async function buildFixture( + mode: 'compile' | 'custom', + service: 'default' | 'user' | 'sharp', + outDirName: string, + ) { + const fixture = await loadFixture({ root: './fixtures/compile-custom-image-service/', - outDir: './dist/compile-custom-image-service/', + outDir: `./dist/compile-custom-image-service-${outDirName}/`, }); - await fixture.build(); - html = await fixture.readFile('client/index.html'); - }); - - it('uses the custom service for markup', () => { - const $ = cheerio.load(html); - const img = $('img'); - - assert.equal(img.attr('data-image-service'), 'custom'); - assert.match(img.attr('src') ?? '', /^\/_astro\/.+\.webp$/); - }); - - it('uses the custom service for generated images', async () => { - const $ = cheerio.load(html); - const src = $('img').attr('src'); - assert.ok(src); - - const data = (await fixture.readFile(`client${src}`, null)) as unknown as Buffer; - assert.equal(Buffer.from(data.subarray(0, 20)).toString('utf8'), 'CUSTOM_TRANSFORM_RAN'); - }); + const resetConfig = await fixture.editFile( + 'astro.config.mjs', + (contents) => { + let next = contents.replace("imageService: 'compile'", `imageService: '${mode}'`); + if (service === 'sharp') { + next = next.replace( + "entrypoint: './src/image-service.ts'", + "entrypoint: './src/sharp-image-service.ts'", + ); + } else if (service === 'default') { + next = next.replace( + "\n\timage: {\n\t\tservice: {\n\t\t\tentrypoint: './src/image-service.ts',\n\t\t},\n\t},", + '', + ); + } + return next; + }, + false, + ); + + try { + await fixture.build(); + return { + fixture, + html: await fixture.readFile('client/index.html'), + }; + } finally { + resetConfig(); + } + } + + async function readGeneratedImage(fixture: Fixture, html: string) { + const src = cheerio.load(html)('img').attr('src'); + assert.match(src ?? '', /^\/_astro\/.+\.webp$/, 'expected a hashed .webp asset in the markup'); + return (await fixture.readFile(`client${src}`, null)) as unknown as Buffer; + } + + for (const mode of ['compile', 'custom'] as const) { + describe(`imageService: '${mode}'`, () => { + it('with no user image.service: generates real WEBP assets at build time', async () => { + const { fixture, html } = await buildFixture(mode, 'default', `${mode}-default`); + + // Build-time generation runs Astro's default Sharp service on the Node side. + assertRealWebp(await readGeneratedImage(fixture, html)); + + const serverBundle = await readServerBundle(fixture); + if (mode === 'compile') { + // `compile` resolves to the workerd-safe service, so Sharp stays out of the + // worker bundle (it only runs on the Node side at build time). + assertSharpNotBundled(serverBundle); + } else { + // `custom` leaves Astro's default Sharp service as the runtime service, so it is + // dragged into the worker bundle (where it cannot run). This is the documented + // "beware" tradeoff of `custom` without a workerd-safe `image.service`. + assertSharpBundled(serverBundle); + } + }); + + it('with a Sharp-free user image.service: runs its transform() at build time and respects its markup, without bundling Sharp', async () => { + const { fixture, html } = await buildFixture(mode, 'user', `${mode}-user`); + const img = cheerio.load(html)('img'); + + assert.equal(img.attr('data-image-service'), 'custom'); + + // The user service's transform() ran during the build (prepends a marker). + const data = await readGeneratedImage(fixture, html); + assert.equal(Buffer.from(data.subarray(0, 20)).toString('utf8'), 'CUSTOM_TRANSFORM_RAN'); + + // The user service is bundled, but it is Sharp-free so Sharp stays out. + const serverBundle = await readServerBundle(fixture); + assert.match(serverBundle, /src\/image-service\.ts/); + assertSharpNotBundled(serverBundle); + + if (mode === 'compile') { + // Runtime serves the prerendered assets through the passthrough endpoint. + assert.match(serverBundle, /image-passthrough-endpoint/); + } else { + // `custom` keeps the user service live at runtime via the generic endpoint. + assert.match(serverBundle, /astro\/dist\/assets\/endpoint\/generic\.js/); + assert.doesNotMatch(serverBundle, /image-passthrough-endpoint/); + } + }); + + it('with a Sharp-backed user image.service: generates assets, respects its markup, and bundles the Sharp chain', async () => { + const { fixture, html } = await buildFixture(mode, 'sharp', `${mode}-sharp`); + const img = cheerio.load(html)('img'); + + assert.equal(img.attr('data-image-service'), 'custom-sharp'); + assertRealWebp(await readGeneratedImage(fixture, html)); + + // The user opted into a Sharp-backed runtime service, so the Sharp chain + // is expected in the worker bundle. + const serverBundle = await readServerBundle(fixture); + assert.match(serverBundle, /src\/sharp-image-service\.ts/); + assertSharpBundled(serverBundle); + }); + }); + } }); diff --git a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs index 7915b95ff56b..f3f1dafd43f5 100644 --- a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs +++ b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs @@ -1,9 +1,8 @@ import cloudflare from '@astrojs/cloudflare'; import { defineConfig } from 'astro/config'; -// `imageService: 'compile'` with a user-defined `image.service`. The adapter -// should preserve the custom service for getURL/getHTMLAttributes AND invoke its -// transform() during the build-time generation pass (instead of hardcoding sharp). +// Tests rewrite this baseline config to cover build-time image generation modes +// with and without a user-defined `image.service`. export default defineConfig({ adapter: cloudflare({ imageService: 'compile', diff --git a/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/sharp-image-service.ts b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/sharp-image-service.ts new file mode 100644 index 000000000000..ffb4a972a28f --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/sharp-image-service.ts @@ -0,0 +1,13 @@ +import type { LocalImageService } from 'astro'; +import sharpService from 'astro/assets/services/sharp'; + +const service: LocalImageService = { + ...sharpService, + + getHTMLAttributes(options, config) { + const attrs = sharpService.getHTMLAttributes?.(options, config) ?? {}; + return { ...attrs, 'data-image-service': 'custom-sharp' }; + }, +}; + +export default service; From 4ee39a87d62e84d1e694b42b489df2edc5a3ae1f Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Tue, 30 Jun 2026 15:26:44 -0400 Subject: [PATCH 4/5] Update .changeset/cloudflare-compile-respect-image-service.md Co-authored-by: Armand Philippot --- .../cloudflare-compile-respect-image-service.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/cloudflare-compile-respect-image-service.md b/.changeset/cloudflare-compile-respect-image-service.md index 01a9deaa605d..3ee8abd019ac 100644 --- a/.changeset/cloudflare-compile-respect-image-service.md +++ b/.changeset/cloudflare-compile-respect-image-service.md @@ -1,5 +1,12 @@ --- -'@astrojs/cloudflare': patch +'@astrojs/cloudflare': minor --- -Fixes build-time image generation in the Cloudflare adapter so a custom `image.service` is respected in `imageService: 'compile'` mode, and extends `imageService: 'custom'` to also generate optimized image assets at build time. +Adds configured image service support with the `compile` and `custom` options. + +The Cloudflare adapter supports various options that affect how images are processed for both pre-rendered and on-demand routes: +- Setting `imageService: 'compile'` now ensures it is used for pre-rendered routes. When no custom image service is defined, the behavior remains unchanged. +- With `imageService: 'custom'`, assets are now processed at build time for pre-rendered routes. If you have configured an image service, it will be bundled to handle images at runtime; otherwise, the behavior remains unchanged. +- The other `imageService` options remain unchanged. + +Learn more about the [image service options](https://docs.astro.build/en/guides/integrations-guide/cloudflare/#imageservice) available in the Cloudflare adapter guide. From f159e0bafb1d824ec6232ddc9dd5389fbc48dc5a Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Wed, 1 Jul 2026 12:56:06 -0400 Subject: [PATCH 5/5] test(cloudflare): skip real-Sharp build-time image tests on Windows CI The default-Sharp and Sharp-backed build-time image tests invoke Astro's real Sharp native binary, which fails to load on the Windows CI runner (ERR_DLOPEN_FAILED). Skip those two on win32 while keeping full real-WEBP coverage on Linux/macOS. The Sharp-free user service runs everywhere. --- .../cloudflare/test/compile-image-service.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts index 44db423aeb3c..b0147eb918c2 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -91,6 +91,14 @@ describe('CompileImageService', () => { // | 'custom' | none (default Sharp) | real WEBP | Sharp dragged in (beware) | // | 'custom' | custom, Sharp-free | CUSTOM_* | user service, no Sharp | // | 'custom' | custom, Sharp-backed | real WEBP | Sharp chain bundled | +// +// The `default` and Sharp-backed `sharp` cases generate assets with Astro's real +// Sharp native binary at build time, which cannot load on every CI runner (notably +// the Windows runner: `ERR_DLOPEN_FAILED`). Those two tests are skipped on Windows; +// the Sharp-free `user` service runs its stub transform() on the Node side and is +// exercised on all platforms. +const skipRealSharp = + process.platform === 'win32' && 'Sharp native binary cannot load on Windows CI'; describe('CompileImageService build-time image generation', () => { async function readServerBundle(fixture: Fixture) { const serverFiles = await fixture.glob('server/**/*.mjs'); @@ -186,7 +194,7 @@ describe('CompileImageService build-time image generation', () => { for (const mode of ['compile', 'custom'] as const) { describe(`imageService: '${mode}'`, () => { - it('with no user image.service: generates real WEBP assets at build time', async () => { + it('with no user image.service: generates real WEBP assets at build time', { skip: skipRealSharp }, async () => { const { fixture, html } = await buildFixture(mode, 'default', `${mode}-default`); // Build-time generation runs Astro's default Sharp service on the Node side. @@ -230,7 +238,7 @@ describe('CompileImageService build-time image generation', () => { } }); - it('with a Sharp-backed user image.service: generates assets, respects its markup, and bundles the Sharp chain', async () => { + it('with a Sharp-backed user image.service: generates assets, respects its markup, and bundles the Sharp chain', { skip: skipRealSharp }, async () => { const { fixture, html } = await buildFixture(mode, 'sharp', `${mode}-sharp`); const img = cheerio.load(html)('img');