Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b305c97
fix: use `@astrojs/cloudflare/image-endpoint` for Cloudflare dev server
rururux Feb 8, 2026
015b212
Change the entrypoint configuration method
rururux Feb 10, 2026
26a8abd
Merge branch 'main' into cloudflare-image-component
rururux Feb 10, 2026
aabfd32
Merge branch 'fix/hahahah-picomatch-cjs' into cloudflare-image-component
Princesseuh Feb 12, 2026
31e58e9
fix(assets): precompile dev deny glob pattern to avoid CJS deps
Princesseuh Feb 11, 2026
2d9a5ec
feat: change default to bindings
Princesseuh Feb 12, 2026
9152bfd
fix: explain comment
Princesseuh Feb 12, 2026
24d2e58
Merge branch 'main' into cloudflare-image-component
Princesseuh Feb 12, 2026
04380dc
fix: use generic endpoint in passthrough
Princesseuh Feb 12, 2026
f518c40
chore: lockfile
Princesseuh Feb 12, 2026
b4dd89c
Merge branch 'main' into cloudflare-image-component
Princesseuh Feb 13, 2026
442d013
chore: changeset
Princesseuh Feb 13, 2026
943c250
Merge remote-tracking branch 'origin/main' into cloudflare-image-comp…
OliverSpeir Feb 16, 2026
260c122
feat: cache cloudflare-binding transforms
OliverSpeir Feb 15, 2026
66a44d7
feat: fix/improve compile
OliverSpeir Feb 16, 2026
a829eb6
Merge branch 'main' into cloudflare-image-component
Princesseuh Feb 23, 2026
f0a8d3f
remove `debug` package
rururux Feb 23, 2026
6d1c5bd
fix changesets
OliverSpeir Feb 23, 2026
d62a625
Update cloudflare-image-service-object.md
OliverSpeir Feb 23, 2026
ee449bd
Update cloudflare-image-component.md
OliverSpeir Feb 23, 2026
233774b
Apply suggestion from @sarah11918
Princesseuh Feb 23, 2026
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/cloudflare-image-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@astrojs/cloudflare": major
---

Changes the default image service from `compile` to `cloudflare-binding`. Image services options that resulted in broken images in development due to Node JS incompatiblities have now been updated to use the noop passthrough image service in dev mode. - ([Cloudflare v13 and Astro6 upgrade guidance](https://v6.docs.astro.build/en/guides/integrations-guide/cloudflare/#changed-imageservice-default))
5 changes: 5 additions & 0 deletions .changeset/cloudflare-image-dev-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Improves compatibility of the built-in image endpoint with runtimes that don't support CJS dependencies correctly
19 changes: 19 additions & 0 deletions .changeset/cloudflare-image-service-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@astrojs/cloudflare": minor
---

Adds support for configuring the image service as an object with separate `build` and `runtime` options

It is now possible to set both a build time and runtime service independently. Currently, `'compile'` is the only available build time option. The supported runtime options are `'passthrough'` (default) and `'cloudflare-binding'`:

```js title="astro.config.mjs" ins={6}
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
adapter: cloudflare({
imageService: { build: 'compile', runtime: 'cloudflare-binding' }
}),
});

See the [Cloudflare adapter `imageService` docs](/en/guides/integrations-guide/cloudflare/#imageservice) for more information about configuring your image service.
1 change: 0 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@
"common-ancestor-path": "^2.0.0",
"cookie": "^1.1.1",
"cssesc": "^3.0.0",
"deterministic-object-hash": "^2.0.2",
"devalue": "^5.6.3",
"diff": "^8.0.3",
"dlv": "^1.1.3",
Expand Down
20 changes: 9 additions & 11 deletions packages/astro/src/assets/build/generate.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import fs, { readFileSync } from 'node:fs';
import { basename } from 'node:path/posix';
import colors from 'piccolore';
import type { BuildApp } from '../../core/build/app.js';
import { getOutDirWithinCwd } from '../../core/build/common.js';
import type { StaticBuildOptions } from '../../core/build/types.js';
import { getTimeStat } from '../../core/build/util.js';
import { AstroError } from '../../core/errors/errors.js';
import { AstroErrorData } from '../../core/errors/index.js';
Expand Down Expand Up @@ -50,14 +50,12 @@ type ImageData = {
};

export async function prepareAssetsGenerationEnv(
app: BuildApp,
options: StaticBuildOptions,
totalCount: number,
): Promise<AssetEnv> {
const settings = app.getSettings();
const logger = app.logger;
const manifest = app.getManifest();
const { settings, logger } = options;
let useCache = true;
const assetsCacheDir = new URL('assets/', app.manifest.cacheDir);
const assetsCacheDir = new URL('assets/', settings.config.cacheDir);
const count = { total: totalCount, current: 1 };

// Ensure that the cache directory exists
Expand All @@ -75,11 +73,11 @@ export async function prepareAssetsGenerationEnv(
let serverRoot: URL, clientRoot: URL;
if (isServerOutput) {
// Images are collected during prerender, which outputs to .prerender/ subdirectory
serverRoot = new URL('.prerender/', manifest.buildServerDir);
clientRoot = manifest.buildClientDir;
serverRoot = new URL('.prerender/', settings.config.build.server);
clientRoot = settings.config.build.client;
} else {
serverRoot = getOutDirWithinCwd(manifest.outDir);
clientRoot = manifest.outDir;
serverRoot = getOutDirWithinCwd(settings.config.outDir);
clientRoot = settings.config.outDir;
}

return {
Expand All @@ -91,7 +89,7 @@ export async function prepareAssetsGenerationEnv(
serverRoot,
clientRoot,
imageConfig: settings.config.image,
assetsFolder: manifest.assetsDir,
assetsFolder: settings.config.build.assets,
};
}

Expand Down
19 changes: 3 additions & 16 deletions packages/astro/src/assets/endpoint/dev.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// @ts-expect-error
import { safeModulePaths, viteFSConfig } from 'astro:assets';
import { fsDenyGlob, safeModulePaths, viteFSConfig } from 'astro:assets';
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import picomatch from 'picomatch';
import { type AnymatchFn, isFileLoadingAllowed, type ResolvedConfig } from 'vite';
import type { APIRoute } from '../../types/public/common.js';
import { handleImageRequest, loadRemoteImage } from './shared.js';
Expand All @@ -21,24 +20,12 @@ async function loadLocalImage(src: string, url: URL) {
}

// Vite only uses the fs config, but the types ask for the full config
// fsDenyGlob's implementation is internal from https://github.com/vitejs/vite/blob/e6156f71f0e21f4068941b63bcc17b0e9b0a7455/packages/vite/src/node/config.ts#L1931

if (
fsPath &&
isFileLoadingAllowed(
{
fsDenyGlob: picomatch(
// matchBase: true does not work as it's documented
// https://github.com/micromatch/picomatch/issues/89
// convert patterns without `/` on our side for now
viteFSConfig.deny.map((pattern: string) =>
pattern.includes('/') ? pattern : `**/${pattern}`,
),
{
matchBase: false,
nocase: true,
dot: true,
},
),
fsDenyGlob,
server: { fs: viteFSConfig },
safeModulePaths,
} as unknown as ResolvedConfig & { fsDenyGlob: AnymatchFn; safeModulePaths: Set<string> },
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/assets/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { getConfiguredImageService, getImage } from './internal.js';
export { baseService, isLocalService } from './services/service.js';
export { hashTransform, propsToFilename } from './utils/hash.js';
export type { LocalImageProps, RemoteImageProps } from './types.js';
149 changes: 149 additions & 0 deletions packages/astro/src/assets/utils/deterministic-string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Vendored from deterministic-object-hash@2.0.2 (MIT)
* https://github.com/nicholasgasior/deterministic-object-hash
*
* Only `deterministicString` is needed - the async `deterministicHash` (which
* pulls in `node:crypto`) is intentionally excluded so this module stays
* runtime-agnostic (works in Node, workerd, browsers, etc.).
*/

const objConstructorString = Function.prototype.toString.call(Object);

function isPlainObject(value: unknown): value is Record<string | symbol, unknown> {
if (
typeof value !== 'object' ||
value === null ||
Object.prototype.toString.call(value) !== '[object Object]'
) {
return false;
}
const proto = Object.getPrototypeOf(value);
if (proto === null) {
return true;
}
if (!Object.prototype.hasOwnProperty.call(proto, 'constructor')) {
return false;
}
return (
typeof proto.constructor === 'function' &&
proto.constructor instanceof proto.constructor &&
Function.prototype.toString.call(proto.constructor) === objConstructorString
);
}

/** Recursively serializes any JS value into a deterministic string. */
export function deterministicString(input: unknown): string {
if (typeof input === 'string') {
return JSON.stringify(input);
} else if (typeof input === 'symbol' || typeof input === 'function') {
return input.toString();
} else if (typeof input === 'bigint') {
return `${input}n`;
} else if (
input === globalThis ||
input === undefined ||
input === null ||
typeof input === 'boolean' ||
typeof input === 'number' ||
typeof input !== 'object'
) {
return `${input}`;
} else if (input instanceof Date) {
return `(${input.constructor.name}:${input.getTime()})`;
} else if (
input instanceof RegExp ||
input instanceof Error ||
input instanceof WeakMap ||
input instanceof WeakSet
) {
return `(${input.constructor.name}:${input.toString()})`;
} else if (input instanceof Set) {
let ret = `(${input.constructor.name}:[`;
for (const val of input.values()) {
ret += `${deterministicString(val)},`;
}
ret += '])';
return ret;
} else if (
Array.isArray(input) ||
input instanceof Int8Array ||
input instanceof Uint8Array ||
input instanceof Uint8ClampedArray ||
input instanceof Int16Array ||
input instanceof Uint16Array ||
input instanceof Int32Array ||
input instanceof Uint32Array ||
input instanceof Float32Array ||
input instanceof Float64Array ||
input instanceof BigInt64Array ||
input instanceof BigUint64Array
) {
let ret = `(${input.constructor.name}:[`;
for (const [k, v] of input.entries()) {
ret += `(${k}:${deterministicString(v)}),`;
}
ret += '])';
return ret;
} else if (input instanceof ArrayBuffer || input instanceof SharedArrayBuffer) {
if (input.byteLength % 8 === 0) {
return deterministicString(new BigUint64Array(input));
} else if (input.byteLength % 4 === 0) {
return deterministicString(new Uint32Array(input));
} else if (input.byteLength % 2 === 0) {
return deterministicString(new Uint16Array(input));
} else {
let ret = '(';
for (let i = 0; i < input.byteLength; i++) {
ret += `${deterministicString(new Uint8Array(input.slice(i, i + 1)))},`;
}
ret += ')';
return ret;
}
} else if (input instanceof Map || isPlainObject(input)) {
const sortable: [string, string][] = [];
const entries = input instanceof Map ? input.entries() : Object.entries(input);
for (const [k, v] of entries) {
sortable.push([deterministicString(k), deterministicString(v)]);
}
if (!(input instanceof Map)) {
const symbolKeys = Object.getOwnPropertySymbols(input);
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < symbolKeys.length; i++) {
sortable.push([
deterministicString(symbolKeys[i]!),
deterministicString((input as Record<symbol, unknown>)[symbolKeys[i]!]),
]);
}
}
sortable.sort(([a], [b]) => a.localeCompare(b));
let ret = `(${input.constructor.name}:[`;
for (const [k, v] of sortable) {
ret += `(${k}:${v}),`;
}
ret += '])';
return ret;
}

const allEntries: [string, string][] = [];
for (const k in input) {
allEntries.push([
deterministicString(k),
deterministicString((input as Record<string, unknown>)[k]),
]);
}
const symbolKeys = Object.getOwnPropertySymbols(input);
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < symbolKeys.length; i++) {
allEntries.push([
deterministicString(symbolKeys[i]!),
deterministicString((input as Record<symbol, unknown>)[symbolKeys[i]!]),
]);
}
allEntries.sort(([a], [b]) => a.localeCompare(b));
let ret = `(${input.constructor.name}:[`;
for (const [k, v] of allEntries) {
ret += `(${k}:${v}),`;
}
ret += '])';
return ret;
}
75 changes: 75 additions & 0 deletions packages/astro/src/assets/utils/hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { deterministicString } from './deterministic-string.js';
import { removeQueryString } from '@astrojs/internal-helpers/path';
import { shorthash } from '../../runtime/server/shorthash.js';
import type { ImageTransform } from '../types.js';
import { isESMImportedImage } from './imageKind.js';

// Taken from https://github.com/rollup/rollup/blob/a8647dac0fe46c86183be8596ef7de25bc5b4e4b/src/utils/sanitizeFileName.ts
// eslint-disable-next-line no-control-regex
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F]/g;

/** Pure-string replacement for `path.posix.basename` (no node: import). */
function basename(filePath: string, ext?: string): string {
let end = filePath.length;
while (end > 0 && filePath[end - 1] === '/') end--;
const stripped = filePath.slice(0, end);

const lastSlash = stripped.lastIndexOf('/');
const base = lastSlash === -1 ? stripped : stripped.slice(lastSlash + 1);

if (ext && base.endsWith(ext)) {
return base.slice(0, base.length - ext.length);
}
return base;
}

/** Pure-string replacement for `path.posix.dirname` (no node: import). */
function dirname(filePath: string): string {
const lastSlash = filePath.lastIndexOf('/');
if (lastSlash === -1) return '.';
if (lastSlash === 0) return '/';
return filePath.slice(0, lastSlash);
}

/** Pure-string replacement for `path.posix.extname` (no node: import). */
function extname(filePath: string): string {
const base = basename(filePath);
const dotIndex = base.lastIndexOf('.');
if (dotIndex <= 0) return '';
return base.slice(dotIndex);
}

/**
* Converts a file path and transformation properties into a formatted filename.
*
* `<prefixDirname>/<baseFilename>_<hash><outputExtension>`
*/
export function propsToFilename(filePath: string, transform: ImageTransform, hash: string): string {
let filename = decodeURIComponent(removeQueryString(filePath));
const ext = extname(filename);
if (filePath.startsWith('data:')) {
filename = shorthash(filePath);
} else {
filename = basename(filename, ext).replace(INVALID_CHAR_REGEX, '_');
}
const prefixDirname = isESMImportedImage(transform.src) ? dirname(filePath) : '';

let outputExt = transform.format ? `.${transform.format}` : ext;
return `${prefixDirname}/${filename}_${hash}${outputExt}`;
}

/** Hashes the subset of transform properties that affect the output image. */
export function hashTransform(
transform: ImageTransform,
imageService: string,
propertiesToHash: string[],
): string {
const hashFields = propertiesToHash.reduce(
(acc, prop) => {
acc[prop] = transform[prop];
return acc;
},
{ imageService } as Record<string, unknown>,
);
return shorthash(deterministicString(hashFields));
}
Loading