diff --git a/.changeset/fix-cloudflare-static-image-service.md b/.changeset/fix-cloudflare-static-image-service.md new file mode 100644 index 000000000000..709f3baeb0b2 --- /dev/null +++ b/.changeset/fix-cloudflare-static-image-service.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +--- + +Fixes the default `imageService` for static output mode. Previously, the default `cloudflare-binding` image service generated `/_image?href=...` runtime URLs that returned 404 when deployed, since there is no server runtime to handle image transformation requests in static mode. The default now automatically uses `compile` for static output, which generates optimized image files at build time. diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 44c3f57f3d02..00cb83345257 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -12,6 +12,7 @@ import { astroFrontmatterScanPlugin } from './esbuild-plugin-astro-frontmatter.j import { getParts } from './utils/generate-routes-json.js'; import { type ImageServiceConfig, + type ImageServiceMode, normalizeImageServiceConfig, setImageConfig, } from './utils/image-config.js'; @@ -128,8 +129,9 @@ export default function createIntegration({ let _routes: IntegrationResolvedRoute[]; let cfPluginConfig: PluginConfig; - const { buildService, runtimeService } = normalizeImageServiceConfig(imageService); - const needsImagesBinding = runtimeService === 'cloudflare-binding'; + let buildService: ImageServiceMode; + let runtimeService: ImageServiceMode; + let needsImagesBinding: boolean; return { name: '@astrojs/cloudflare', @@ -139,6 +141,15 @@ export default function createIntegration({ throw new Error('`workerd` does not run on Stackblitz.'); } + // Resolve image service config with output mode awareness. + // For static output, defaults to `compile` since there is no server + // runtime to handle `/_image` requests at deploy time. + ({ buildService, runtimeService } = normalizeImageServiceConfig( + imageService, + config.output, + )); + needsImagesBinding = runtimeService === 'cloudflare-binding'; + let session = config.session; const isCompile = buildService === 'compile'; @@ -380,7 +391,7 @@ export default function createIntegration({ cfPrismPlugin(), ], }, - image: setImageConfig(imageService, config.image, command, logger), + image: setImageConfig(imageService, config.image, command, logger, config.output), }); if (cloudflareOptions.configPath) { diff --git a/packages/integrations/cloudflare/src/utils/image-config.ts b/packages/integrations/cloudflare/src/utils/image-config.ts index 473bd6993718..6eaaa81b6e90 100644 --- a/packages/integrations/cloudflare/src/utils/image-config.ts +++ b/packages/integrations/cloudflare/src/utils/image-config.ts @@ -16,12 +16,18 @@ export type ImageServiceConfig = }; /** Normalize string | compound config into separate build/runtime modes. */ -export function normalizeImageServiceConfig(config: ImageServiceConfig | undefined): { +export function normalizeImageServiceConfig( + config: ImageServiceConfig | undefined, + output?: 'static' | 'server', +): { buildService: ImageServiceMode; runtimeService: ImageServiceMode; } { if (!config || typeof config === 'string') { - const mode = config ?? 'cloudflare-binding'; + // For static output, default to `compile` since there is no server runtime + // to handle `/_image` requests. For server output, default to `cloudflare-binding` + // which uses the Cloudflare Images binding for runtime transforms. + const mode = config ?? (output === 'static' ? 'compile' : 'cloudflare-binding'); // `compile` is build-only; at runtime, serve pre-compiled static assets return { buildService: mode, @@ -53,8 +59,9 @@ export function setImageConfig( config: AstroConfig['image'], command: HookParameters<'astro:config:setup'>['command'], logger: AstroIntegrationLogger, + output?: 'static' | 'server', ) { - const { buildService, runtimeService } = normalizeImageServiceConfig(service); + const { buildService, runtimeService } = normalizeImageServiceConfig(service, output); switch (buildService) { case 'passthrough': diff --git a/packages/integrations/cloudflare/test/units/image-config.test.ts b/packages/integrations/cloudflare/test/units/image-config.test.ts new file mode 100644 index 000000000000..5829df844f1f --- /dev/null +++ b/packages/integrations/cloudflare/test/units/image-config.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { normalizeImageServiceConfig } from '../../dist/utils/image-config.js'; + +describe('normalizeImageServiceConfig', () => { + it('defaults to compile for static output', () => { + const result = normalizeImageServiceConfig(undefined, 'static'); + assert.equal(result.buildService, 'compile'); + assert.equal(result.runtimeService, 'passthrough'); + }); + + it('defaults to cloudflare-binding for server output', () => { + const result = normalizeImageServiceConfig(undefined, 'server'); + assert.equal(result.buildService, 'cloudflare-binding'); + assert.equal(result.runtimeService, 'cloudflare-binding'); + }); + + it('defaults to cloudflare-binding when output is not specified', () => { + const result = normalizeImageServiceConfig(undefined, undefined); + assert.equal(result.buildService, 'cloudflare-binding'); + assert.equal(result.runtimeService, 'cloudflare-binding'); + }); + + it('respects explicit cloudflare-binding even with static output', () => { + const result = normalizeImageServiceConfig('cloudflare-binding', 'static'); + assert.equal(result.buildService, 'cloudflare-binding'); + assert.equal(result.runtimeService, 'cloudflare-binding'); + }); + + it('respects explicit compile with server output', () => { + const result = normalizeImageServiceConfig('compile', 'server'); + assert.equal(result.buildService, 'compile'); + assert.equal(result.runtimeService, 'passthrough'); + }); + + it('respects explicit passthrough regardless of output', () => { + const result = normalizeImageServiceConfig('passthrough', 'static'); + assert.equal(result.buildService, 'passthrough'); + assert.equal(result.runtimeService, 'passthrough'); + }); + + it('handles compound config regardless of output', () => { + const result = normalizeImageServiceConfig( + { build: 'compile', runtime: 'cloudflare-binding' }, + 'static', + ); + assert.equal(result.buildService, 'compile'); + assert.equal(result.runtimeService, 'cloudflare-binding'); + }); +});