Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/cloudflare-compile-respect-image-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Fixes build-time image generation in the Cloudflare adapter so a custom `image.service` is respected in `imageService: 'compile'` mode, and extends `imageService: 'custom'` to also generate optimized image assets at build time.
Comment thread
adamchal marked this conversation as resolved.
Outdated
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 @@ -369,14 +380,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 @@ -467,7 +480,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 @@ -137,7 +139,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 @@ -154,10 +156,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
167 changes: 167 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,170 @@ 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 |
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', 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', 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.
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading