Skip to content

feat(cloudflare): refactor image service config#15662

Draft
OliverSpeir wants to merge 8 commits intomainfrom
feat/improve-cloudflare-imageservices-config
Draft

feat(cloudflare): refactor image service config#15662
OliverSpeir wants to merge 8 commits intomainfrom
feat/improve-cloudflare-imageservices-config

Conversation

@OliverSpeir
Copy link
Copy Markdown
Contributor

@OliverSpeir OliverSpeir commented Feb 25, 2026

Changes

Normalises all imageService config into a { buildService, devService, runtimeService } triple, makes custom Node-only image services work with compile, adds a Vite dev middleware that uses jiti to dynamically import the image service entrypoint in Node, so custom TypeScript services work in dev

Config shapes

export default defineConfig({
  adapter: cloudflare({
    imageService: 'compile',
  }),
});
export default defineConfig({
  adapter: cloudflare({
    imageService: './src/my-service.ts'
  }),
});
export default defineConfig({
  adapter: cloudflare({
    imageService: {
      entrypoint: './src/my-service.ts',
      config: { quality: 80 },
    },
  }),
});
export default defineConfig({
  adapter: cloudflare({
    imageService: {
      build: 'sharp', // just an example lol 
      dev: './src/my-dev-service.ts',
      runtime: './src/my-runtime-service.ts',
      transformAtBuild: false,
    }
  }),
});

Available presets: cloudflare-binding (default) | cloudflare | compile | passthrough | custom (deprecated)

What each preset resolves to

Preset buildService devService runtimeService transformsAtBuild
cloudflare-binding (default) image-service-workerd image-service-workerd image-service-workerd false
cloudflare image-service (cdn-cgi) sharp image-service (cdn-cgi) false
compile image-service-workerd (stub) sharp passthrough true
passthrough noop noop noop false
custom (deprecated) image-service-workerd (stub) sharp user's config.image.service true

When transformsAtBuild is true, images are transformed at build time in Node (via Sharp or a custom service), and the deployed Worker just serves transformed static assets.

Other api facets

  • imageService: 'sharp' throws with guidance to use 'compile' instead
  • imageService: 'custom' deprecated, eventually will be copy paste from their astro config.images to the config.adapter
  • Any integration that sets config.image.service in config:setup is automatically picked up by the compile preset (captured in config:done) (this means integrations like Chris's sweetcorn dithering tool will work with compile)

Testing

added tests

Docs

Haven't written yet, will need to though, ideally get feedback on if this is the right api before writing them

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Feb 25, 2026

🦋 Changeset detected

Latest commit: e6f8ab6

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added the pkg: integration Related to any renderer integration (scope) label Feb 25, 2026
@OliverSpeir
Copy link
Copy Markdown
Contributor Author

Ai file-by-file breakdown

1. Rewrite image-config.ts — the normalisation engine

packages/integrations/cloudflare/src/utils/image-config.ts

The old normalizeImageServiceConfig returned { buildService, runtimeService } where both were string modes ('passthrough' | 'cloudflare' | 'compile' | ...). The new version returns a NormalizedImageConfig with full Astro ImageServiceConfig objects for each phase:

interface NormalizedImageConfig {
  buildService: AstroImageServiceConfig;   // what runs during prerendering in workerd
  devService: AstroImageServiceConfig;     // what runs in dev
  runtimeService: AstroImageServiceConfig; // what's bundled into the deployed Worker
  transformsAtBuild: boolean;              // whether Node-side transforms happen after prerender
  serviceEntrypoint?: string;  // which entrypoint to compile as a Node-side Rollup chunk for build-time transforms
}

normalizeImageServiceConfig() handles every config shape — preset strings, bare entrypoints, { entrypoint, config } shorthand, and the full { build, dev, runtime } triple. Unknown entrypoints are auto-detected against WORKERD_COMPATIBLE_ENTRYPOINTS; if the entrypoint isn't in that set, the system assumes it needs Node and enables transformsAtBuild.

setImageConfig() is now a pure function that takes the normalised triple and the current command, and picks the right service and endpoint for Astro's config.image:

// In config:setup
updateConfig({
  image: setImageConfig(normalized, config.image, command, logger),
});

Dev uses devService for both service and endpoint. Build uses buildService for service (what runs during prerendering) but runtimeService for endpoint (the /_image handler bundled into the deployed Worker).

2. Node middleware in dev — vite-plugin-dev-image-middleware.ts

packages/integrations/cloudflare/src/vite-plugin-dev-image-middleware.ts

When devService points to a Node-only service (Sharp, a custom .ts service, etc.), workerd can't load it. This plugin adds a Vite middleware that intercepts /_image requests before they reach the workerd proxy:

server.middlewares.use(async (req, res, next) => {
  if (!req.url?.startsWith(route)) return next();
  if (!jiti) jiti = createJiti(import.meta.url);
  const mod = await jiti.import(entrypoint);
  // ... parseURL, loadSourceImage, transform, respond
});

Why jiti? The dev service entrypoint may be a TypeScript file (./src/my-service.ts). Vite's ssrLoadModule can't be used here because we're in a Vite middleware — we need to load the module outside the Vite module graph. jiti handles TypeScript transpilation at runtime with zero config, making it the right tool for dynamically importing arbitrary TS entrypoints in a Node context.

The middleware loads images either from the Vite dev server (local files via fetch to localhost:${port}) or via fetch for remote URLs (respecting config.image.remotePatterns).

3. Virtual module interceptor — vite-plugin-image-service.ts

packages/integrations/cloudflare/src/vite-plugin-image-service.ts

Astro core resolves virtual:image-service to config.image.service.entrypoint. But in a Cloudflare build, the prerender environment (workerd) and the SSR environment (runtime Worker) need different services. This plugin hijacks virtual:image-service before Astro core can resolve it:

// Generated virtual module:
import { isPrerender } from 'virtual:astro-cloudflare:config';
import prerenderService from '@astrojs/cloudflare/image-service-workerd';
import runtimeService from 'astro/assets/services/noop';
export default isPrerender ? prerenderService : runtimeService;

This keeps Node-only code completely out of all Worker bundles. The isPrerender flag comes from the existing virtual:astro-cloudflare:config module (set by vite-plugin-config.ts).

The second plugin (emitter) compiles the custom image service as a standalone Rollup chunk in the SSR build. This "smuggles" the real service into the server output directory where prerenderer.ts can import() it in Node after prerendering. The chunk is deleted after build (index.ts astro:build:done).

4. Workerd stub gets config-aware hashing

packages/integrations/cloudflare/src/entrypoints/image-service-workerd.ts

The workerd stub generates URLs and hashes during prerendering but doesn't transform pixels. Two additions ensure the stub produces hashes that match what the real service would produce:

propertiesToHash: [...(baseService.propertiesToHash ?? []), '_serviceConfig'],

validateOptions(options, imageConfig) {
  const validated = baseService.validateOptions!(options, imageConfig);
  const config = imageConfig?.service?.config;
  if (config && Object.keys(config).length > 0) {
    (validated as any)._serviceConfig = config;
  }
  return validated;
},

Without this, two services with identical image transforms but different configs (e.g. { quality: 80 } vs { quality: 90 }) would generate the same hash/filename, causing cache collisions. The _serviceConfig field is included in the hash input so different configs produce different filenames.

5. Prerenderer loads custom service from compiled chunk

packages/integrations/cloudflare/src/prerenderer.ts

Previously, collectStaticImages() always switched to Sharp for Node-side transforms. Now it loads the emitted chunk instead:

const servicePath = getServicePath();
if (servicePath) {
  const absolutePath = resolve(fileURLToPath(serverDir), servicePath);
  const mod = await import(pathToFileURL(absolutePath).href);
  globalThis.astroAsset.imageService = {
    ...mod.default,
    async transform(buf, opts, imgConfig) {
      // Re-validate with the compiled service — the workerd stub's
      // validateOptions doesn't know about custom service defaults
      if (svc.validateOptions) opts = await svc.validateOptions(opts, imgConfig);
      return svc.transform(buf, opts, imgConfig);
    },
  };
}

The re-validation step is necessary because the workerd stub ran validateOptions during prerendering but doesn't know about the custom service's defaults or transformations. The compiled service may add/modify options that affect the final transform.

Falls back to Sharp with a warning if no compiled chunk is found (shouldn't happen, but provides a safety net).

6. Integration wiring in index.ts

packages/integrations/cloudflare/src/index.ts

The integration hooks wire everything together:

config:setup: Normalises the config once at the top of the integration. Conditionally adds the image service interceptor (when build and runtime services differ) and dev middleware (when dev service needs Node). The IMAGES binding logic now checks actual entrypoints instead of string modes.

config:done: Captures the final config.image.service.entrypoint after all integrations have run. This is how compile automatically picks up services set by other integrations

build:start: Passes the new hasTransformAtBuildService, getServicePath, and logger to the prerenderer.

build:done: Deletes the emitted image service chunk — it was only needed during the build for Node-side transforms, not at runtime.

7. Tests

packages/integrations/cloudflare/test/

Four new test files covering:

  • image-config.test.js — exhaustive tests for expandPreset, normalizeImageServiceConfig, resolveEndpoint, and setImageConfig across all config shapes
  • image-service-plugin.test.js — unit tests for the interceptor and emitter plugins (resolveId, load, applyToEnvironment, emitFile/generateBundle lifecycle)
  • dev-image-middleware.test.js — middleware registration, route matching, base path handling
  • image-service-workerd.test.js — config-aware hashing (different configs produce different hash inputs, absent config doesn't inject _serviceConfig)

@alexanderniebuhr
Copy link
Copy Markdown
Member

@OliverSpeir can we get this shipped? 👀


- `cloudflare-binding` (default) — uses the Cloudflare Images binding (`IMAGES`) for transforms
- `cloudflare` — uses Cloudflare's CDN (`cdn-cgi/image`) for URL-based transforms
- `compile` — Sharp at build time and in dev, passthrough at runtime (pre-optimized assets served as-is)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `compile` — Sharp at build time and in dev, passthrough at runtime (pre-optimized assets served as-is)
- `prerender-sharp` — Sharp at build time and in dev, passthrough at runtime (pre-optimized assets served as-is)

Idea to make this understandable. Users might not understand compile, why not build. I suggest prerender-sharp. Let me explain. prerender because it only applies to pages with prerender = true, which is familar to Astro users. sharp because we should explicitly say what local solution we use, as there could be other.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: integration Related to any renderer integration (scope)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants