Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
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
29 changes: 29 additions & 0 deletions packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type * as fsMod from 'node:fs';
import { extname } from 'node:path';
import MagicString from 'magic-string';
import picomatch from 'picomatch';
import type * as vite from 'vite';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import type { Logger } from '../core/logger/core.js';
Expand Down Expand Up @@ -174,6 +175,8 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
Array.from(resolvedConfig.safeModulePaths ?? []),
)});

export const fsDenyGlob = ${serializeFsDenyGlob(resolvedConfig.server.fs?.deny ?? [])};

const assetQueryParams = ${
settings.adapter?.client?.assetQueryParams
? `new URLSearchParams(${JSON.stringify(
Expand Down Expand Up @@ -319,3 +322,29 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
fontsPlugin({ settings, sync, logger }),
];
}

// Precompile the denies patterns to avoid using a CJS package in the runtime
function serializeFsDenyGlob(denyPatterns: string[]): string {
// Replicate the same pattern transformation that Vite does internally:
// https://github.com/vitejs/vite/blob/e6156f71f0e21f4068941b63bcc17b0e9b0a7455/packages/vite/src/node/config.ts#L1931
const expandedPatterns = denyPatterns.map((pattern) =>
pattern.includes('/') ? pattern : `**/${pattern}`,
);

const regexes = expandedPatterns.map((pattern) =>
picomatch.makeRe(pattern, {
matchBase: false,
nocase: true,
dot: true,
}),
);

// Serialize the regexes as a function that tests a path against all patterns
const serializedRegexes = regexes.map((re) => re.toString()).join(', ');
return `(function() {
const regexes = [${serializedRegexes}];
return function fsDenyGlob(testPath) {
return regexes.some(re => re.test(testPath));
};
})()`;
}
11 changes: 9 additions & 2 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ export default function createIntegration(args?: Options): AstroIntegration {
config: cloudflareConfigCustomizer({
sessionKVBindingName: args?.sessionKVBindingName,
imagesBindingName:
args?.imageService === 'cloudflare-binding' ? args?.imagesBindingName : false,
args?.imageService === 'cloudflare-binding' || !args?.imageService
? args?.imagesBindingName
: false,
}),
experimental: {
prerenderWorker: {
Expand Down Expand Up @@ -224,7 +226,12 @@ export default function createIntegration(args?: Options): AstroIntegration {
}),
],
},
image: setImageConfig(args?.imageService ?? 'compile', config.image, command, logger),
image: setImageConfig(
args?.imageService ?? 'cloudflare-binding',
config.image,
command,
logger,
),
});

addWatchFile(new URL('./wrangler.toml', config.root));
Expand Down
24 changes: 18 additions & 6 deletions packages/integrations/cloudflare/src/utils/image-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export type ImageService =
| 'compile'
| 'custom';

// The default Astro dev image endpoint uses node:fs which is unavailable in workerd.
// Use the generic endpoint instead, which loads images via fetch through the dev server.
const GENERIC_ENDPOINT = { entrypoint: 'astro/assets/endpoint/generic' };

export function setImageConfig(
service: ImageService,
config: AstroConfig['image'],
Expand All @@ -16,16 +20,21 @@ export function setImageConfig(
) {
switch (service) {
case 'passthrough':
return { ...config, service: passthroughImageService() };
return {
...config,
service: passthroughImageService(),
endpoint: command === 'dev' ? GENERIC_ENDPOINT : config.endpoint,
};

case 'cloudflare':
if (command === 'dev') {
return { ...config, service: passthroughImageService(), endpoint: GENERIC_ENDPOINT };
}
return {
...config,
service:
command === 'dev'
? sharpImageService()
: { entrypoint: '@astrojs/cloudflare/image-service' },
service: { entrypoint: '@astrojs/cloudflare/image-service' },
};

case 'cloudflare-binding':
return {
...config,
Expand All @@ -35,11 +44,14 @@ export function setImageConfig(
};

case 'compile':
if (command === 'dev') {
return { ...config, service: passthroughImageService(), endpoint: GENERIC_ENDPOINT };
}
return {
...config,
service: sharpImageService(),
endpoint: {
entrypoint: command === 'dev' ? undefined : '@astrojs/cloudflare/image-endpoint',
entrypoint: '@astrojs/cloudflare/image-endpoint',
},
};

Expand Down
117 changes: 75 additions & 42 deletions packages/integrations/cloudflare/test/compile-image-service.test.js
Original file line number Diff line number Diff line change
@@ -1,63 +1,96 @@
import * as assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import { loadFixture } from './_test-utils.js';

describe('CompileImageService', () => {
let fixture;
let previewServer;

before(async () => {
fixture = await loadFixture({
root: './fixtures/compile-image-service/',
});
await fixture.build();
previewServer = await fixture.preview();
});

after(async () => {
await previewServer.stop();
});
describe('dev', () => {
let devServer;

it('forbids http://', async () => {
const res = await fixture.fetch('/_image?href=http://placehold.co/600x400');
const html = await res.text();
const status = res.status;
assert.equal(html, 'Forbidden');
assert.equal(status, 403);
});
before(async () => {
devServer = await fixture.startDevServer();
});

it('forbids https://', async () => {
const res = await fixture.fetch('/_image?href=https://placehold.co/600x400');
const html = await res.text();
const status = res.status;
assert.equal(html, 'Forbidden');
assert.equal(status, 403);
});
after(async () => {
await devServer.stop();
});

it('forbids //', async () => {
const res = await fixture.fetch('/_image?href=//placehold.co/600x400');
const html = await res.text();
const status = res.status;
assert.equal(html, 'Blocked');
assert.equal(status, 403);
// In dev, the compile service falls back to passthrough because sharp cannot run in workerd. Images are served unoptimized
// through the /_image endpoint.
it('returns 200 for local images via /_image endpoint', async () => {
const html = await fixture.fetch('/blog/post').then((res) => res.text());
const $ = cheerio.load(html);
const src = $('img').attr('src');
assert.ok(
src.startsWith('/_image'),
`Expected image src to route through /_image, got: ${src}`,
);
const res = await fixture.fetch(src);
assert.equal(res.status, 200);
});
});

it('allows trusted with redirect', async () => {
const res = await fixture.fetch(
'/_image?href=https://astro.build/_astro/HeroBackground.B0iWl89K_2hpsgp.webp',
{ redirect: 'manual' },
);
const header = res.headers.get('location');
const status = res.status;
assert.equal(header, 'https://astro.build/_astro/HeroBackground.B0iWl89K_2hpsgp.webp');
assert.equal(status, 302);
});
describe('preview', () => {
let previewServer;

before(async () => {
await fixture.build();
previewServer = await fixture.preview();
});

after(async () => {
await previewServer.stop();
});

it('forbids http://', async () => {
const res = await fixture.fetch('/_image?href=http://placehold.co/600x400');
const html = await res.text();
const status = res.status;
assert.equal(html, 'Forbidden');
assert.equal(status, 403);
});

it('forbids https://', async () => {
const res = await fixture.fetch('/_image?href=https://placehold.co/600x400');
const html = await res.text();
const status = res.status;
assert.equal(html, 'Forbidden');
assert.equal(status, 403);
});

it('allows local', async () => {
const res = await fixture.fetch('/_image?href=/_astro/placeholder.gLBdjEDe.jpg');
const blob = await res.blob();
const status = res.status;
assert.equal(blob.type, 'image/jpeg');
assert.equal(status, 200);
it('forbids //', async () => {
const res = await fixture.fetch('/_image?href=//placehold.co/600x400');
const html = await res.text();
const status = res.status;
assert.equal(html, 'Blocked');
assert.equal(status, 403);
});

it('allows trusted with redirect', async () => {
const res = await fixture.fetch(
'/_image?href=https://astro.build/_astro/HeroBackground.B0iWl89K_2hpsgp.webp',
{ redirect: 'manual' },
);
const header = res.headers.get('location');
const status = res.status;
assert.equal(header, 'https://astro.build/_astro/HeroBackground.B0iWl89K_2hpsgp.webp');
assert.equal(status, 302);
});

it('allows local', async () => {
const res = await fixture.fetch('/_image?href=/_astro/placeholder.gLBdjEDe.jpg');
const blob = await res.blob();
const status = res.status;
assert.equal(blob.type, 'image/jpeg');
assert.equal(status, 200);
});
});
});
48 changes: 48 additions & 0 deletions packages/integrations/cloudflare/test/dev-image-endpoint.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { loadFixture } from './_test-utils.js';

describe('Dev image endpoint', () => {
let fixture;
let devServer;

before(async () => {
fixture = await loadFixture({
root: './fixtures/dev-image-endpoint/',
});
devServer = await fixture.startDevServer();
});

after(async () => {
await devServer.stop();
});

it('returns 403 for missing href parameter', async () => {
const res = await fixture.fetch('/_image?f=webp');
assert.equal(res.status, 403);
});

it('returns 403 for disallowed remote images', async () => {
const res = await fixture.fetch('/_image?href=https://example.com/image.jpg&f=webp');
assert.equal(res.status, 403);
});

it('returns 400 for unsupported format', async () => {
const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=png');
const text = await res.text();
assert.equal(res.status, 400);
assert.ok(text.includes('not supported'));
});

it('transforms local images to webp', async () => {
const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=webp&w=100');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/webp');
});

it('transforms local images to avif', async () => {
const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=avif&w=100');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/avif');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';

export default defineConfig({
adapter: cloudflare(),
image: {
domains: ['astro.build'],
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-dev-image-endpoint",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
---

<html lang="en">
<head>
<meta charset="utf-8" />
<title>Dev Image Endpoint Test</title>
</head>
<body>
<h1>Dev Image Endpoint Test</h1>
</body>
</html>
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.