diff --git a/.changeset/cloudflare-compile-respect-image-service.md b/.changeset/cloudflare-compile-respect-image-service.md new file mode 100644 index 000000000000..3ee8abd019ac --- /dev/null +++ b/.changeset/cloudflare-compile-respect-image-service.md @@ -0,0 +1,12 @@ +--- +'@astrojs/cloudflare': minor +--- + +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. diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index fdf88adea275..567c1675deec 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,9 +139,11 @@ export default function createIntegration({ let _routes: IntegrationResolvedRoute[]; let cfPluginConfig: PluginConfig; + let hasUserBuildImageService = false; const { buildService, runtimeService } = normalizeImageServiceConfig(imageService); const needsImagesBinding = runtimeService === 'cloudflare-binding'; + const hasBuildImageService = buildService === 'compile' || buildService === 'custom'; return { name: '@astrojs/cloudflare', @@ -145,12 +155,13 @@ export default function createIntegration({ let session = config.session; const isCompile = buildService === 'compile'; + 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.`, ); @@ -382,14 +393,16 @@ 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: '@astrojs/cloudflare/image-service-workerd', + imageServiceEntrypoint: hasUserBuildImageService + ? config.image.service.entrypoint + : '@astrojs/cloudflare/image-service-workerd', buildAssets: config.build.assets ?? '_astro', } : null, @@ -481,7 +494,10 @@ export default function createIntegration({ base: _config.base, trailingSlash: _config.trailingSlash, cfPluginConfig, - hasCompileImageService: buildService === 'compile', + 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 735e3b695ac4..a3607f1b3d63 100644 --- a/packages/integrations/cloudflare/src/prerenderer.ts +++ b/packages/integrations/cloudflare/src/prerenderer.ts @@ -27,7 +27,8 @@ interface CloudflarePrerendererOptions { base: AstroConfig['base']; trailingSlash: AstroConfig['trailingSlash']; cfPluginConfig: PluginConfig; - hasCompileImageService: boolean; + hasBuildImageService: boolean; + userImageServiceEntrypoint?: string; } /** @@ -41,7 +42,8 @@ export function createCloudflarePrerenderer({ base, trailingSlash, cfPluginConfig, - hasCompileImageService, + hasBuildImageService, + userImageServiceEntrypoint, }: CloudflarePrerendererOptions): AstroPrerenderer { let previewServer: VitePreviewServer | undefined; let serverUrl: string; @@ -146,7 +148,7 @@ export function createCloudflarePrerenderer({ return response; }, - collectStaticImages: hasCompileImageService + collectStaticImages: hasBuildImageService ? async (): Promise => { const response = await fetch(`${serverUrl}${STATIC_IMAGES_ENDPOINT}`, { method: 'POST', @@ -163,10 +165,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..b0147eb918c2 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -79,3 +79,178 @@ describe('CompileImageService', () => { }); }); }); + +// 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 | +// +// 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'); + 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-${outDirName}/`, + }); + 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', { 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. + 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', { skip: skipRealSharp }, 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 new file mode 100644 index 000000000000..f3f1dafd43f5 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/astro.config.mjs @@ -0,0 +1,16 @@ +import cloudflare from '@astrojs/cloudflare'; +import { defineConfig } from 'astro/config'; + +// 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', + }), + 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 000000000000..f4fc88e293c2 Binary files /dev/null and b/packages/integrations/cloudflare/test/fixtures/compile-custom-image-service/src/assets/test.jpg differ 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/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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c70c6fd29d..9fdb5078742c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4327,6 +4327,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':