Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fruity-news-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Fixes `imageService: 'compile'` producing unoptimized images when `prerenderEnvironment` is set to `'node'`
40 changes: 40 additions & 0 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,10 +507,50 @@ export default function createIntegration({
: undefined,
}),
);
} else if (hasBuildImageService) {
// When prerenderEnvironment is 'node', prerendering runs in the same
// Node process using the workerd-safe image service stub (which is a
// passthrough). We need to install the real image service (sharp or
// the user's custom service) before the image generation pipeline runs.
// This mirrors what collectStaticImages does in the workerd prerenderer.
const entrypoint = hasUserBuildImageService
? resolveImageServiceEntrypoint(_config.image.service.entrypoint, _config.root)
: undefined;
setPrerenderer((defaultPrerenderer) => ({
...defaultPrerenderer,
async collectStaticImages() {
globalThis.astroAsset ??= {};
if (entrypoint) {
const mod = await import(entrypoint);
globalThis.astroAsset.imageService = mod.default ?? mod;
} else {
const { default: sharpService } = await import('astro/assets/services/sharp');
globalThis.astroAsset.imageService = sharpService;
}
// Static images are already in globalThis.astroAsset.staticImages
// from the Node-side prerendering. Return an empty map since
// there are no additional images to merge from a separate runtime.
return new Map();
},
}));
}
},
'astro:build:setup': ({ vite, target }) => {
if (target === 'server') {
// When prerenderEnvironment is 'node' and we used setPrerenderer
// to add collectStaticImages for compile-time image optimization,
// the prerender entrypoint gets skipped (because settings.prerenderer
// is truthy). Restore the default entrypoint since we're still using
// the default Node-based prerenderer — we only wrapped it.
if (prerenderEnvironment === 'node' && hasBuildImageService) {
vite.environments ??= {};
vite.environments.prerender ??= {};
(vite.environments.prerender as Record<string, any>).build ??= {};
(vite.environments.prerender as Record<string, any>).build.rolldownOptions ??= {};
(vite.environments.prerender as Record<string, any>).build.rolldownOptions.input =
'astro/entrypoints/prerender';
}

vite.resolve ||= {};
vite.resolve.alias ||= {};
vite.ssr ||= {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import { type DevServer, type Fixture, loadFixture, type PreviewServer } from './test-utils.ts';

const skipRealSharp =
process.platform === 'win32' && 'Sharp native binary cannot load on Windows CI';

describe('CompileImageService', () => {
let fixture: Fixture;
before(async () => {
Expand Down Expand Up @@ -97,8 +100,6 @@ describe('CompileImageService', () => {
// 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');
Expand Down Expand Up @@ -258,3 +259,76 @@ describe('CompileImageService build-time image generation', () => {
});
}
});

describe('CompileImageService with prerenderEnvironment: node', () => {
function assertRealWebp(data: Buffer) {
assert.equal(data.subarray(0, 4).toString('utf8'), 'RIFF');
assert.equal(data.subarray(8, 12).toString('utf8'), 'WEBP');
}

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

it('generates real WEBP assets at build time with prerenderEnvironment: node', {
skip: skipRealSharp,
}, async () => {
const fixture = await loadFixture({
root: './fixtures/compile-custom-image-service/',
outDir: './dist/compile-node-prerender-default/',
});
const resetConfig = await fixture.editFile(
'astro.config.mjs',
(contents) => {
// Remove the user image.service and add prerenderEnvironment: 'node'
let next = contents.replace(
"\n\timage: {\n\t\tservice: {\n\t\t\tentrypoint: './src/image-service.ts',\n\t\t},\n\t},",
'',
);
next = next.replace(
"imageService: 'compile',",
"imageService: 'compile',\n\t\tprerenderEnvironment: 'node',",
);
return next;
},
false,
);

try {
await fixture.build();
const html = await fixture.readFile('client/index.html');
assertRealWebp(await readGeneratedImage(fixture, html));
} finally {
resetConfig();
}
});

it('runs custom Sharp-free image service transform() with prerenderEnvironment: node', async () => {
const fixture = await loadFixture({
root: './fixtures/compile-custom-image-service/',
outDir: './dist/compile-node-prerender-user/',
});
const resetConfig = await fixture.editFile(
'astro.config.mjs',
(contents) => {
return contents.replace(
"imageService: 'compile',",
"imageService: 'compile',\n\t\tprerenderEnvironment: 'node',",
);
},
false,
);

try {
await fixture.build();
const html = await fixture.readFile('client/index.html');
const data = await readGeneratedImage(fixture, html);
// The user service's transform() ran during the build (prepends a marker).
assert.equal(Buffer.from(data.subarray(0, 20)).toString('utf8'), 'CUSTOM_TRANSFORM_RAN');
} finally {
resetConfig();
}
});
});
Loading