Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/cloudflare-respect-custom-image-service.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +5 to +7

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset is too lenghtly and too focused on the technical side of the fix. Changesets are meant for end users, not maintainers. Follow this guide https://contribute.docs.astro.build/docs-for-code-changes/changesets/#tips-and-examples

12 changes: 11 additions & 1 deletion packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -417,7 +427,7 @@ export default function createIntegration({
base: _config.base,
trailingSlash: _config.trailingSlash,
cfPluginConfig,
hasCompileImageService: buildService === 'compile',
hasCompileImageService: buildService === 'compile' && !_hasCustomImageService,
}),
);
}
Expand Down
13 changes: 13 additions & 0 deletions packages/integrations/cloudflare/src/utils/image-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already check for custom in the switch, we just need to add hasCustomService check there and fallback to sharp otherwise.

`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 {
Expand Down
74 changes: 74 additions & 0 deletions packages/integrations/cloudflare/test/image-config.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +10 to +22

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't needed, you can reduce this stub to

const spyLogger = new SpyLogger();
const logger = spyLogger.forkIntegrationLogger('test-spy');

this is what we use in the mdx tests


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 },
]) {
Comment on lines +29 to +37

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we test custom too? That's essentially where the PR is fixing things

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);
});
});
Loading