diff --git a/.changeset/cloudflare-respect-custom-image-service.md b/.changeset/cloudflare-respect-custom-image-service.md new file mode 100644 index 000000000000..c5f882aee35a --- /dev/null +++ b/.changeset/cloudflare-respect-custom-image-service.md @@ -0,0 +1,7 @@ +--- +'@astrojs/cloudflare': patch +--- + +Preserve user-defined image services when the Cloudflare adapter is used. Previously, the adapter's `imageService` mode (including the default `'cloudflare-binding'` and `'compile'`) would silently overwrite a custom `image.service` configured in `astro.config.*`, replacing it with the workerd image service. Custom services (e.g., third-party CDNs) are now preserved across all modes, matching the behavior of the explicit `'custom'` mode. + +Additionally, the workerd prerenderer no longer hard-swaps `globalThis.astroAsset.imageService` to `astro/assets/services/sharp` for byte-level static image generation when a custom service is in use. Previously the `'compile'` mode bypassed the preserved service during the Node-side image generation pass; it now uses the user's configured service throughout the build. diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index d006b312ec24..e75199c26058 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -123,6 +123,11 @@ export default function createIntegration({ let _routes: IntegrationResolvedRoute[]; let _isFullyStatic = false; let cfPluginConfig: PluginConfig; + // True when the user provided a non-default `image.service`, which `setImageConfig` + // preserves across all `imageService` modes. In that case the workerd stub never + // becomes the active service, so the Node-side `collectStaticImages` path (which + // hard-swaps to sharp) must not run — it would clobber the user's service. + let _hasCustomImageService = false; const { buildService, runtimeService } = normalizeImageServiceConfig(imageService); const needsImagesBinding = runtimeService === 'cloudflare-binding'; @@ -135,6 +140,11 @@ export default function createIntegration({ throw new Error('`workerd` does not run on Stackblitz.'); } + // Read this from the user's pre-`updateConfig` image config, since + // `setImageConfig` below may replace `service` with the workerd stub. + _hasCustomImageService = + config.image.service.entrypoint !== 'astro/assets/services/sharp'; + let session = config.session; const isCompile = buildService === 'compile'; @@ -417,7 +427,7 @@ export default function createIntegration({ base: _config.base, trailingSlash: _config.trailingSlash, cfPluginConfig, - hasCompileImageService: buildService === 'compile', + hasCompileImageService: buildService === 'compile' && !_hasCustomImageService, }), ); } diff --git a/packages/integrations/cloudflare/src/utils/image-config.ts b/packages/integrations/cloudflare/src/utils/image-config.ts index 473bd6993718..0d28b158e0f8 100644 --- a/packages/integrations/cloudflare/src/utils/image-config.ts +++ b/packages/integrations/cloudflare/src/utils/image-config.ts @@ -56,6 +56,19 @@ export function setImageConfig( ) { const { buildService, runtimeService } = normalizeImageServiceConfig(service); + // If the user has configured a non-default image service, preserve it across + // all `imageService` modes. This matches the explicit `'custom'` mode and the + // fallback branch below, and prevents the adapter from silently overriding a + // user-defined service (e.g., a third-party CDN) when the default mode is in + // effect or `'compile'`/`'cloudflare-binding'` is selected. + const hasCustomService = config.service.entrypoint !== 'astro/assets/services/sharp'; + if (hasCustomService && buildService !== 'custom') { + logger.info( + `Detected a custom image service (${config.service.entrypoint}). Preserving it instead of applying the '${buildService}' mode override. Set \`imageService: 'custom'\` to silence this notice.`, + ); + return { ...config }; + } + switch (buildService) { case 'passthrough': return { 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..5913fa2e52b2 --- /dev/null +++ b/packages/integrations/cloudflare/test/image-config.test.ts @@ -0,0 +1,74 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AstroConfig, AstroIntegrationLogger } from 'astro'; +import { setImageConfig } from '../src/utils/image-config.ts'; + +const SHARP_ENTRYPOINT = 'astro/assets/services/sharp'; +const CUSTOM_ENTRYPOINT = 'my-app/image-services/cdn'; +const WORKERD_ENTRYPOINT = '@astrojs/cloudflare/image-service-workerd'; + +function createNoopLogger(): AstroIntegrationLogger { + const noop = () => {}; + const logger = { + options: {} as never, + label: 'test', + fork: () => logger, + info: noop, + warn: noop, + error: noop, + debug: noop, + }; + return logger as unknown as AstroIntegrationLogger; +} + +function makeConfig(entrypoint: string): AstroConfig['image'] { + return { service: { entrypoint, config: {} } } as AstroConfig['image']; +} + +describe('setImageConfig — custom image service preservation', () => { + for (const mode of [ + undefined, // default → 'cloudflare-binding' + 'passthrough', + 'cloudflare', + 'cloudflare-binding', + 'compile', + { build: 'compile' as const }, + { build: 'compile' as const, runtime: 'cloudflare-binding' as const }, + ]) { + it(`preserves a user-defined service when imageService = ${JSON.stringify(mode)}`, () => { + const customConfig = makeConfig(CUSTOM_ENTRYPOINT); + const result = setImageConfig(mode as any, customConfig, 'build', createNoopLogger()); + assert.equal( + result.service.entrypoint, + CUSTOM_ENTRYPOINT, + `Expected custom service to be preserved for mode ${JSON.stringify(mode)}`, + ); + }); + } + + it("does not preserve when user has the default sharp service (mode 'cloudflare-binding')", () => { + const sharpConfig = makeConfig(SHARP_ENTRYPOINT); + const result = setImageConfig('cloudflare-binding', sharpConfig, 'build', createNoopLogger()); + assert.equal( + result.service.entrypoint, + WORKERD_ENTRYPOINT, + 'Expected adapter to override default sharp for cloudflare-binding mode', + ); + }); + + it("does not preserve when user has the default sharp service (mode 'compile')", () => { + const sharpConfig = makeConfig(SHARP_ENTRYPOINT); + const result = setImageConfig('compile', sharpConfig, 'build', createNoopLogger()); + assert.equal( + result.service.entrypoint, + WORKERD_ENTRYPOINT, + 'Expected adapter to override default sharp for compile mode', + ); + }); + + it("respects an explicit 'custom' mode regardless of service", () => { + const customConfig = makeConfig(CUSTOM_ENTRYPOINT); + const result = setImageConfig('custom', customConfig, 'build', createNoopLogger()); + assert.equal(result.service.entrypoint, CUSTOM_ENTRYPOINT); + }); +});