Skip to content
Draft
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
36 changes: 36 additions & 0 deletions .changeset/cloudflare-image-service-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@astrojs/cloudflare': minor
---

Adds flexible `imageService` configuration for the Cloudflare adapter, with named presets, a Vite dev middleware that uses `jiti` to dynamically import custom image services in Node, and support for per-phase service configuration.

The `imageService` option now accepts named presets, a bare entrypoint, a shorthand object, or a full per-phase triple:

```js
// Named preset (recommended)
imageService: 'compile'

// Bare entrypoint — unknown entrypoints default to transformsAtBuild: true
imageService: './src/my-image-service.ts'

// Shorthand with config
imageService: { entrypoint: './src/my-image-service.ts', config: { quality: 80 } }

// Full control over each phase
imageService: {
build: './src/my-build-service.ts',
dev: './src/my-dev-service.ts',
runtime: './src/my-runtime-service.ts',
transformAtBuild: false,
}
```

**Available presets:**

- `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.

- `passthrough` — no transforms anywhere
- `custom` (deprecated) — workerd stub for build, Sharp in dev, user's `config.image.service` at runtime

`imageService: 'custom'` is deprecated. It uses the workerd stub for build, Sharp for dev, and preserves the user's existing `config.image.service` at runtime.
1 change: 1 addition & 0 deletions packages/integrations/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@astrojs/internal-helpers": "workspace:*",
"@astrojs/underscore-redirects": "workspace:*",
"@cloudflare/vite-plugin": "^1.25.2",
"jiti": "^2.6.1",
"piccolore": "^0.1.3",
"tinyglobby": "^0.2.15",
"vite": "^7.3.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ import { baseService } from 'astro/assets';
const service: LocalImageService = {
...baseService,

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

async transform(inputBuffer, transform) {
return { data: inputBuffer, format: transform.format };
},
Expand Down
181 changes: 144 additions & 37 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import type { AstroConfig, AstroIntegration, IntegrationResolvedRoute } from 'as
import { astroFrontmatterScanPlugin } from './esbuild-plugin-astro-frontmatter.js';
import { getParts } from './utils/generate-routes-json.js';
import {
type ImageServiceConfig,
type ImageServiceOption,
WORKERD_COMPATIBLE_ENTRYPOINTS,
normalizeImageServiceConfig,
setImageConfig,
} from './utils/image-config.js';
import { createDevImageMiddlewarePlugin } from './vite-plugin-dev-image-middleware.js';
import { createConfigPlugin } from './vite-plugin-config.js';
import { createImageServicePlugins } from './vite-plugin-image-service.js';
import {
cloudflareConfigCustomizer,
DEFAULT_SESSION_KV_BINDING_NAME,
Expand All @@ -22,6 +25,7 @@ import { parseEnv } from 'node:util';
import { sessionDrivers } from 'astro/config';
import { createCloudflarePrerenderer } from './prerenderer.js';
import { createRequire } from 'node:module';
import { createJiti } from 'jiti';

export type { Runtime } from './utils/handler.js';

Expand All @@ -31,7 +35,7 @@ export interface Options
'auxiliaryWorkers' | 'configPath' | 'inspectorPort' | 'persistState' | 'remoteBindings'
> {
/** Options for handling images. */
imageService?: ImageServiceConfig;
imageService?: ImageServiceOption;

/**
* By default, Astro will be configured to use Cloudflare KV to store session data. The KV namespace
Expand Down Expand Up @@ -68,13 +72,47 @@ export default function createIntegration({
...cloudflareOptions
}: Options = {}): AstroIntegration {
let _config: AstroConfig;

let _routes: IntegrationResolvedRoute[];
let _isFullyStatic = false;
let cfPluginConfig: PluginConfig;
/** Relative path to the emitted image service file in the server output dir. */
let _servicePath: string | undefined;

/**
* Mutable config object shared with `createConfigPlugin` via closure.
* Populated in `config:setup`, then updated in `config:done` to add
* the custom service's `propertiesToHash`.
*/
let _transformAtBuildConfig: import('./vite-plugin-config.js').TransformAtBuildConfig | null =
null;

const { buildService, runtimeService } = normalizeImageServiceConfig(imageService);
const needsImagesBinding = runtimeService === 'cloudflare-binding';
const normalized = normalizeImageServiceConfig(imageService);

/**
* Custom image service entrypoint for Node-side image transforms.
* Initialized from explicit triple config, or captured in config:done
* when a later integration overwrites config.image.service.
*/
let _buildServiceEntrypoint: string | undefined = normalized.serviceEntrypoint;
const { devService, runtimeService } = normalized;

const needsImagesBinding =
runtimeService.entrypoint === '@astrojs/cloudflare/image-service-workerd';
const transformsAtBuild = normalized.transformsAtBuild;
const isPresetConfig = imageService === undefined || typeof imageService === 'string';
const devServiceNeedsNode = !WORKERD_COMPATIBLE_ENTRYPOINTS.has(devService.entrypoint);

// Sharp auto-switches to passthrough at runtime (setImageConfig guard),
// so account for that when comparing entrypoints for the virtual module.
const effectiveRuntimeEntrypoint =
normalized.runtimeService.entrypoint === 'astro/assets/services/sharp'
? 'astro/assets/services/noop'
: normalized.runtimeService.entrypoint;

// The interceptor is needed when the build and runtime services differ
// (e.g. compile mode: workerd stub for prerender, passthrough for runtime).
const needsInterceptor =
normalized.buildService.entrypoint !== effectiveRuntimeEntrypoint;

return {
name: '@astrojs/cloudflare',
Expand All @@ -84,17 +122,19 @@ export default function createIntegration({
throw new Error('`workerd` does not run on Stackblitz.');
}

// TODO: remove when 'custom' preset is removed
if (imageService === 'custom') {
logger.warn(
`imageService: 'custom' is deprecated. Use the adapter's imageService option directly with { entrypoint, config } or a named preset instead.`,
);
}

let session = config.session;
const isCompile = buildService === 'compile';

if (needsImagesBinding) {
logger.info(
`Enabling image processing with Cloudflare Images for production with the "${imagesBindingName}" Images binding.`,
);
} else if (isCompile) {
logger.info(
`Enabling compile-time image optimization. Images will be pre-optimized at build time.`,
);
}

if (!session?.driver) {
Expand All @@ -111,10 +151,13 @@ export default function createIntegration({
};
}

// In dev, `compile` needs the IMAGES binding for real transforms
// (the image-transform-endpoint uses it). At build time,
// `compile` uses Sharp on the Node side instead.
const needsImagesBindingForDev = isCompile && command === 'dev';
// In dev, if the dev image service uses the workerd stub and
// the Node middleware isn't handling images, the IMAGES binding
// is needed for real transforms via image-transform-endpoint.
const needsImagesBindingForDev =
command === 'dev' &&
devService.entrypoint === '@astrojs/cloudflare/image-service-workerd' &&
!devServiceNeedsNode;

cfPluginConfig = {
config: cloudflareConfigCustomizer({
Expand All @@ -138,8 +181,8 @@ export default function createIntegration({
},
};

// The preview entrypoint uses Cloudflare's vite plugin and so it needs access
// to the config. But there's no proper API for this so we use globalThis.
// The preview entrypoint (entrypoints/preview.ts) needs the Cloudflare Vite plugin
// config but there's no API to pass it directly, so we bridge via globalThis.
if (command === 'preview') {
globalThis.astroCloudflareOptions = cfPluginConfig;
}
Expand Down Expand Up @@ -231,24 +274,48 @@ export default function createIntegration({
}
},
},
createConfigPlugin({
sessionKVBindingName,
compileImageConfig:
isCompile && command !== 'dev'
? {
base: config.base,
assetsPrefix:
typeof config.build.assetsPrefix === 'string'
? config.build.assetsPrefix
: undefined,
imageServiceEntrypoint: '@astrojs/cloudflare/image-service-workerd',
buildAssets: config.build.assets ?? '_astro',
}
: null,
}),
(() => {
if (transformsAtBuild && command !== 'dev') {
_transformAtBuildConfig = {
base: config.base,
assetsPrefix:
typeof config.build.assetsPrefix === 'string'
? config.build.assetsPrefix
: undefined,
imageServiceEntrypoint: '@astrojs/cloudflare/image-service-workerd',
buildAssets: config.build.assets ?? '_astro',
};
}
return createConfigPlugin({
sessionKVBindingName,
transformAtBuildConfig: _transformAtBuildConfig,
});
})(),
// In dev there's no bundle split — the Node middleware handles image transforms
// directly, so the virtual:image-service interceptor isn't needed.
...(needsInterceptor && command !== 'dev'
? createImageServicePlugins({
prerenderEntrypoint: normalized.buildService.entrypoint,
runtimeEntrypoint: effectiveRuntimeEntrypoint,
getBuildServiceEntrypoint: () => _buildServiceEntrypoint,
onService: (relativePath) => {
_servicePath = relativePath;
},
})
: []),
...(devServiceNeedsNode && command === 'dev'
? [
createDevImageMiddlewarePlugin({
getDevServiceEntrypoint: () =>
_buildServiceEntrypoint ?? devService.entrypoint,
getImageConfig: () => _config.image,
base: config.base,
}),
]
: []),
],
},
image: setImageConfig(imageService, config.image, command, logger),
image: setImageConfig(normalized, config.image, command, logger),
});

if (cloudflareOptions.configPath) {
Expand All @@ -266,9 +333,40 @@ export default function createIntegration({
_isFullyStatic =
nonInternalRoutes.length > 0 && nonInternalRoutes.every((route) => route.isPrerendered);
},
'astro:config:done': ({ setAdapter, config, injectTypes, logger }) => {
'astro:config:done': async ({ setAdapter, config, injectTypes, logger }) => {
_config = config;

// Capture final entrypoint after all integrations have run their config:setup.
if (transformsAtBuild) {
const finalEntrypoint = config.image.service?.entrypoint;
if (
finalEntrypoint &&
finalEntrypoint !== 'astro/assets/services/sharp' &&
finalEntrypoint !== 'astro/assets/services/noop' &&
finalEntrypoint !== '@astrojs/cloudflare/image-service-workerd'
) {
_buildServiceEntrypoint = finalEntrypoint;
}

// Forward the custom service's propertiesToHash so the workerd
// stub produces matching cache hashes during prerendering.
if (_buildServiceEntrypoint && _transformAtBuildConfig) {
try {
const jiti = createJiti(config.root.pathname);
const mod = await jiti.import(_buildServiceEntrypoint);
const service = (mod as any).default ?? mod;
if (Array.isArray(service.propertiesToHash)) {
_transformAtBuildConfig.propertiesToHash = service.propertiesToHash;
}
} catch (_e) {
logger.warn(
`Could not resolve propertiesToHash from custom image service "${_buildServiceEntrypoint}". ` +
`Image cache hashes may not include custom properties.`,
);
}
}
}

injectTypes({
filename: 'cloudflare.d.ts',
content: '/// <reference types="@astrojs/cloudflare/types.d.ts" />',
Expand All @@ -291,8 +389,7 @@ export default function createIntegration({
support: 'limited',
message:
'When using a custom image service, ensure it is compatible with the Cloudflare Workers runtime.',
// Only 'custom' could potentially use sharp at runtime.
suppress: buildService === 'custom' ? 'default' : 'all',
suppress: isPresetConfig ? 'all' : 'default',
},
envGetSecret: 'stable',
},
Expand All @@ -313,7 +410,7 @@ export default function createIntegration({
}
}
},
'astro:build:start': ({ setPrerenderer }) => {
'astro:build:start': ({ setPrerenderer, logger }) => {
setPrerenderer(
createCloudflarePrerenderer({
root: _config.root,
Expand All @@ -322,7 +419,10 @@ export default function createIntegration({
base: _config.base,
trailingSlash: _config.trailingSlash,
cfPluginConfig,
hasCompileImageService: buildService === 'compile',
hasTransformAtBuildService: transformsAtBuild,
expectsToTransformAtBuild: !!_buildServiceEntrypoint,
getServicePath: () => _servicePath,
logger,
}),
);
},
Expand Down Expand Up @@ -352,6 +452,13 @@ export default function createIntegration({
}
},
'astro:build:done': async ({ dir, logger, assets }) => {
// Delete the emitted image service file — only needed during the build, not at runtime.
// It was only needed at build time for Node-side image transforms.
if (_servicePath) {
const serviceUrl = new URL(_servicePath, _config.build.server);
await rm(serviceUrl, { force: true });
}

let redirectsExists = false;
try {
const redirectsStat = await stat(new URL('./_redirects', _config.build.client));
Expand Down
Loading
Loading