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
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'`
62 changes: 62 additions & 0 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,10 +519,72 @@ 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;
Comment thread
alexanderniebuhr marked this conversation as resolved.
setPrerenderer((defaultPrerenderer) => ({
...defaultPrerenderer,
async collectStaticImages() {
globalThis.astroAsset ??= {};
if (entrypoint) {
// Belt-and-braces rather than load-bearing: with a user-configured
// image.service, the Node prerender bundle already loads the user
// service via virtual:image-service and caches it here whenever a
// page renders an image, so this re-import only exists for symmetry
// with the workerd prerenderer's collectStaticImages. Guard it: the
// raw entrypoint import can fail where the bundled service works
// (e.g. TypeScript entrypoints on Node versions without type
// stripping), and an empty cache means no image was rendered, so
// the service is never used by the generation pipeline anyway.
if (!globalThis.astroAsset.imageService) {
try {
const mod = await import(entrypoint);
globalThis.astroAsset.imageService = mod.default ?? mod;
} catch {
// Unused when no images were rendered — never fail the build.
}
}
} 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.
//
// NOTE: the entrypoint specifier and config shape below mirror the
// skip logic in packages/astro/src/core/build/vite-build-config.ts
// (the `rolldownOptions.input` handling for the prerender
// environment). If core renames 'astro/entrypoints/prerender' or
// reshapes that config, this must be updated in lockstep — otherwise
// builds silently degrade back to unoptimized image output.
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';
Comment thread
alexanderniebuhr marked this conversation as resolved.
}

vite.resolve ||= {};
vite.resolve.alias ||= {};
vite.ssr ||= {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ 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';

// Tests that generate assets with Astro's real Sharp native binary at build time
// (the `default` and Sharp-backed `sharp` cases below) cannot run on every CI
// runner (notably the Windows runner: `ERR_DLOPEN_FAILED`) and 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', () => {
let fixture: Fixture;
before(async () => {
Expand Down Expand Up @@ -92,13 +100,8 @@ describe('CompileImageService', () => {
// | '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';
// The `default` and Sharp-backed `sharp` cases run Astro's real Sharp native
// binary at build time and are skipped on Windows (see `skipRealSharp`).
describe('CompileImageService build-time image generation', () => {
async function readServerBundle(fixture: Fixture) {
const serverFiles = await fixture.glob('server/**/*.mjs');
Expand Down Expand Up @@ -258,3 +261,85 @@ 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({
Comment thread
alexanderniebuhr marked this conversation as resolved.
root: './fixtures/compile-custom-image-service/',
outDir: './dist/compile-node-prerender-default/',
// A dedicated cache is required for this test to be able to fail: the
// earlier suites generate the exact same transforms through the workerd
// path, so with the shared node_modules/.astro assets cache this test
// would pass on cache hits alone even with the fix reverted.
cacheDir: './node_modules/.astro-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();
}
});

// Scenario coverage, not a regression test for #17346: with a user-configured
// image.service, the Node prerender bundle already loads the user service via
// virtual:image-service, so this scenario worked even before the fix.
it('runs custom Sharp-free image service transform() with prerenderEnvironment: node', async () => {
Comment thread
alexanderniebuhr marked this conversation as resolved.
const fixture = await loadFixture({
root: './fixtures/compile-custom-image-service/',
outDir: './dist/compile-node-prerender-user/',
cacheDir: './node_modules/.astro-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