From 2d6ddb5b2a998e04173edf91c3fda543d155eee7 Mon Sep 17 00:00:00 2001 From: astrobot-houston Date: Fri, 10 Jul 2026 00:57:33 +0000 Subject: [PATCH 1/8] fix(cloudflare): fix compile image service with prerenderEnvironment: node (#17346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `imageService: 'compile'` was used with `prerenderEnvironment: 'node'`, images were silently copied without optimization (PNG bytes in .webp files). The root cause was that `collectStaticImages` — which installs sharp for build-time transforms — only ran in the workerd prerenderer path. The node prerender path used the workerd passthrough stub instead. Fix: wrap the default prerenderer with a `collectStaticImages` method that installs sharp (or the user's custom service) before image generation runs, and restore the default prerender entrypoint that gets skipped when `settings.prerenderer` is set. --- .changeset/fruity-news-post.md | 5 ++ packages/integrations/cloudflare/src/index.ts | 40 ++++++++++ .../test/compile-image-service.test.ts | 76 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 .changeset/fruity-news-post.md diff --git a/.changeset/fruity-news-post.md b/.changeset/fruity-news-post.md new file mode 100644 index 000000000000..65374e557943 --- /dev/null +++ b/.changeset/fruity-news-post.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +--- + +Fixes `imageService: 'compile'` producing unoptimized images when `prerenderEnvironment` is set to `'node'` diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 4536ef017aec..1ef6a64ef09a 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -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).build ??= {}; + (vite.environments.prerender as Record).build.rolldownOptions ??= {}; + (vite.environments.prerender as Record).build.rolldownOptions.input = + 'astro/entrypoints/prerender'; + } + vite.resolve ||= {}; vite.resolve.alias ||= {}; vite.ssr ||= {}; diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts index ae9bbadffeba..98522c031cc1 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -258,3 +258,79 @@ describe('CompileImageService build-time image generation', () => { }); } }); + +describe('CompileImageService with prerenderEnvironment: node', () => { + const skipRealSharp = + process.platform === 'win32' && 'Sharp native binary cannot load on Windows CI'; + + 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(); + } + }); +}); From fffb45419b0ad0ee716c2fd57f421736e1618463 Mon Sep 17 00:00:00 2001 From: ematipico Date: Fri, 10 Jul 2026 14:37:28 +0100 Subject: [PATCH 2/8] chore: linting --- .../cloudflare/test/compile-image-service.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts index 98522c031cc1..f6e0dfea2d15 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -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 () => { @@ -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'); @@ -260,9 +261,6 @@ describe('CompileImageService build-time image generation', () => { }); describe('CompileImageService with prerenderEnvironment: node', () => { - const skipRealSharp = - process.platform === 'win32' && 'Sharp native binary cannot load on Windows CI'; - function assertRealWebp(data: Buffer) { assert.equal(data.subarray(0, 4).toString('utf8'), 'RIFF'); assert.equal(data.subarray(8, 12).toString('utf8'), 'WEBP'); From 6550ad3f25dfca6f1911738b1deacd361f270bb1 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:33:57 +0200 Subject: [PATCH 3/8] test(cloudflare): give node-prerender image tests dedicated cacheDirs Without cache isolation these tests pass on assets-cache hits from the earlier workerd suites (which generate the identical transforms into the shared node_modules/.astro cache), so they could not fail even with the fix reverted. --- .../cloudflare/test/compile-image-service.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts index f6e0dfea2d15..db93ab8da6d7 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -278,6 +278,11 @@ describe('CompileImageService with prerenderEnvironment: node', () => { const fixture = await loadFixture({ 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', @@ -309,6 +314,7 @@ describe('CompileImageService with prerenderEnvironment: node', () => { 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', From 36c19d48e0ca601bf6ae8d55d01bdfeff44c311d Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:34:09 +0200 Subject: [PATCH 4/8] test(cloudflare): mark user-service node-prerender test as scenario coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a user-configured image.service the Node prerender bundle already loads the user service via virtual:image-service, so this scenario worked before the fix — the test documents behavior rather than guarding the #17346 regression. --- .../integrations/cloudflare/test/compile-image-service.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts index db93ab8da6d7..6e08a52403ae 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -310,6 +310,9 @@ describe('CompileImageService with prerenderEnvironment: node', () => { } }); + // 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 () => { const fixture = await loadFixture({ root: './fixtures/compile-custom-image-service/', From 2e884570f0952ae9b81ad313facae23ba93cea13 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:34:24 +0200 Subject: [PATCH 5/8] test(cloudflare): move Windows Sharp skip explanation next to skipRealSharp The ERR_DLOPEN_FAILED rationale documented the skipRealSharp constant but was left above the build-time generation suite when the constant moved to the top of the file. --- .../cloudflare/test/compile-image-service.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts index 6e08a52403ae..8d004336b47a 100644 --- a/packages/integrations/cloudflare/test/compile-image-service.test.ts +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -3,6 +3,11 @@ 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'; @@ -95,11 +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. +// 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'); From b1918e4894aa8f2e6dff0bf361b3562c917cda90 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:34:38 +0200 Subject: [PATCH 6/8] chore(cloudflare): cross-reference core prerender entrypoint skip logic The restored entrypoint specifier and config shape duplicate the skip condition in packages/astro/src/core/build/vite-build-config.ts; note that they must stay in lockstep since drift silently reintroduces the unoptimized-image failure mode this fix removes. --- packages/integrations/cloudflare/src/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 1ef6a64ef09a..425a4e14fc5b 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -542,6 +542,13 @@ export default function createIntegration({ // 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 ??= {}; From da001bf3fd9ce3f50b58e8f8a4a5f84e0eaab013 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:34:54 +0200 Subject: [PATCH 7/8] chore(cloudflare): document user-service branch of collectStaticImages as defensive With a user-configured image.service the prerender bundle already loads the service via virtual:image-service; the re-import exists for symmetry with the workerd prerenderer, not correctness. --- packages/integrations/cloudflare/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 425a4e14fc5b..9a8cbbea4f7a 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -521,6 +521,10 @@ export default function createIntegration({ 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, so this re-import only exists + // for symmetry with the workerd prerenderer's collectStaticImages. const mod = await import(entrypoint); globalThis.astroAsset.imageService = mod.default ?? mod; } else { From 9d47fc651b1140ffa065cd39eb47ae8d8f7296f7 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:51:39 +0200 Subject: [PATCH 8/8] fix(cloudflare): guard defensive user-service import in node collectStaticImages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw entrypoint import can fail where the bundled service works (e.g. TypeScript entrypoints on Node versions without type stripping), and any registered static image implies the bundled service is already cached in globalThis. Only import when the cache is empty — meaning no image was rendered and the service goes unused — and never fail the build over it. --- packages/integrations/cloudflare/src/index.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 13f9c35db526..c20ac3a45659 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -535,10 +535,21 @@ export default function createIntegration({ 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, so this re-import only exists - // for symmetry with the workerd prerenderer's collectStaticImages. - const mod = await import(entrypoint); - globalThis.astroAsset.imageService = mod.default ?? mod; + // 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;