Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/cloudflare-compile-respect-image-service.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 20 additions & 4 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -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.`,
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}),
);
}
Expand Down
18 changes: 12 additions & 6 deletions packages/integrations/cloudflare/src/prerenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ interface CloudflarePrerendererOptions {
base: AstroConfig['base'];
trailingSlash: AstroConfig['trailingSlash'];
cfPluginConfig: PluginConfig;
hasCompileImageService: boolean;
hasBuildImageService: boolean;
userImageServiceEntrypoint?: string;
}

/**
Expand All @@ -41,7 +42,8 @@ export function createCloudflarePrerenderer({
base,
trailingSlash,
cfPluginConfig,
hasCompileImageService,
hasBuildImageService,
userImageServiceEntrypoint,
}: CloudflarePrerendererOptions): AstroPrerenderer {
let previewServer: VitePreviewServer | undefined;
let serverUrl: string;
Expand Down Expand Up @@ -146,7 +148,7 @@ export function createCloudflarePrerenderer({
return response;
},

collectStaticImages: hasCompileImageService
collectStaticImages: hasBuildImageService
? async (): Promise<AssetsGlobalStaticImagesList> => {
const response = await fetch(`${serverUrl}${STATIC_IMAGES_ENDPOINT}`, {
method: 'POST',
Expand All @@ -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) {
Expand Down
24 changes: 16 additions & 8 deletions packages/integrations/cloudflare/src/utils/image-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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 };
Expand Down
175 changes: 175 additions & 0 deletions packages/integrations/cloudflare/test/compile-image-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
});
Original file line number Diff line number Diff line change
@@ -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',
},
},
});
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading