Skip to content

feat(media): binding-based image transforms for same-origin media (Access-safe)#1494

Closed
scottbuscemi wants to merge 2 commits into
mainfrom
fix/image-transform-binding-access
Closed

feat(media): binding-based image transforms for same-origin media (Access-safe)#1494
scottbuscemi wants to merge 2 commits into
mainfrom
fix/image-transform-binding-access

Conversation

@scottbuscemi

@scottbuscemi scottbuscemi commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds a binding-based image transform service so responsive images work for same-origin media behind Cloudflare Access (and anywhere loopback fetches are restricted, e.g. global_fetch_strictly_public).

Background. The responsive-srcset feature (#1438) hands an absolute media URL to Astro's image service, which makes Astro's /_image endpoint fetch() that URL to load the source bytes before transforming. For same-origin R2/local media that absolute URL is the Worker's own origin, so the load is a self-referential subrequest. When the site is behind Cloudflare Access, that subrequest carries no Access cookie → Access returns a login redirect instead of image bytes → /_image returns 404. It "works locally" only because astro dev fetches a different (public) origin for the source.

Fix. Serve transforms from an EmDash-owned route, GET /_emdash/api/media/transform/{key}, that reads the source bytes straight from the storage adapter (the R2 binding) and resizes them with a configured transformer (the Cloudflare IMAGES binding). The browser requests this route directly — with its own Access cookie — and the Worker never re-fetches the media URL, so there is no loopback subrequest.

The Image / Portable Text image components prefer the transform route for same-origin media when a transformer is configured, and fall back to the existing astro:assets path otherwise (external CDN/publicUrl media, Node + sharp, or no transformer). Behavior is unchanged when images is not configured.

Opt-in wiring on Cloudflare:

import { imageBinding } from "@emdash-cms/cloudflare";

emdash({
  storage: r2({ binding: "MEDIA" }),
  images: imageBinding({ binding: "IMAGES" }), // requires { "images": { "binding": "IMAGES" } } in wrangler
});

Implementation

  • core: ImageServiceDescriptor + virtual:emdash/images (mirrors the storage descriptor pattern so core stays portable); transform route handler; portable URL/srcset builders + param validation in media/image-transform.ts; the transformer is built in middleware and exposed as locals.emdash.transformImage.
  • cloudflare: imageBinding() config helper + media/transform-runtime.ts that uses the IMAGES binding.

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Additive and opt-in (no behavior change unless images is configured). Opened at a maintainer's direction; prior Discussion waived.

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation — n/a, no admin UI strings
  • I have added a changeset
  • New features link to an approved Discussion — n/a, Discussion waived by maintainer

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (opencode)

Screenshots / test output

Test Files  4 passed (4)
     Tests  57 passed (57)   # media/image-transform, astro/routes, middleware-prerender, media/responsive

New tests: tests/unit/media/image-transform.test.ts (URL/srcset builders, key safety, param validation) and transform-route handler + injection coverage in tests/unit/astro/routes.test.ts.


Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks fix/image-transform-binding-access. Updated automatically when the playground redeploys.

Resizing R2/local media routed through Astro's /_image endpoint, which
loads the source by fetching the media's own URL. On Cloudflare that is a
self-referential request that fails behind Cloudflare Access or with
loopback fetches disabled, surfacing as 404s on transformed images.

Add an EmDash-owned transform route (/_emdash/api/media/transform/{key})
that reads source bytes straight from the storage adapter and resizes them
with a configured image transformer — no server-side fetch of the media URL.

- core: ImageServiceDescriptor + virtual:emdash/images wiring, transform
  route, URL/srcset builders, and Image/EmDashImage prefer the route for
  same-origin media when a transformer is configured (falls back to the
  astro:assets path otherwise).
- cloudflare: imageBinding() config helper + IMAGES-binding runtime.
@changeset-bot

changeset-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d0bca58

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

This PR includes changesets to release 14 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 920 lines across 22 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache d0bca58 Jun 15 2026, 11:15 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs d0bca58 Jun 15 2026, 11:14 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground d0bca58 Jun 15 2026, 11:16 PM

@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1494

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1494

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1494

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1494

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1494

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1494

emdash

npm i https://pkg.pr.new/emdash@1494

create-emdash

npm i https://pkg.pr.new/create-emdash@1494

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1494

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1494

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1494

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1494

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1494

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1494

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1494

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1494

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1494

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1494

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1494

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1494

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1494

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1494

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1494

commit: d0bca58

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The approach is sound: serving same-origin transforms via an EmDash-owned route that reads from the storage binding directly fixes the Cloudflare Access loopback-fetch failure cleanly, and the virtual-module/descriptor pattern mirrors the existing storage/auth wiring well. The PR is additive, opt-in, and leaves existing behavior unchanged when images is not configured.

I found two issues that need fixing before merge:

  1. Module-scope singleton cache violates AGENTS.md. The _transformImage variable in middleware.ts is a plain module-scope let. AGENTS.md states: "Module-scope singletons must live on globalThis. Vite duplicates modules across SSR chunks; a plain let cache = null becomes two variables." This cache should use a Symbol.for key on globalThis instead.

  2. Transform route comment says SVGs are refused, but the code lets them through. The startsWith("image/") check passes image/svg+xml. When no transformer is configured, the SVG is streamed through without the CSP sandbox and Content-Disposition: attachment protections that the file route applies for SVGs. The condition should explicitly exclude SVGs to match the documented intent.

Everything else looks good — changeset is present and well-written, the URL builders and param validation are solid, and test coverage is reasonable for the new paths.


// Image transformer is stateless and configured once per deployment; build it
// lazily on first use and reuse it. `null` records "tried and unavailable" so
// a missing binding doesn't re-throw and re-log on every request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] The _transformImage variable is a module-scope singleton cache. AGENTS.md explicitly requires these to live on globalThis because Vite can duplicate modules across SSR chunks, turning a plain let into multiple independent variables:

Module-scope singletons must live on globalThis. Vite duplicates modules across SSR chunks; a plain let cache = null becomes two variables. Use a Symbol.for key on globalThis.

Move the cache to globalThis so it is stable across chunk boundaries. For example:

Suggested change
// a missing binding doesn't re-throw and re-log on every request.
const kTransformImage = Symbol.for("emdash.transformImage");
function getTransformImage(config: EmDashConfig): TransformImageFn | undefined {
const cached = (globalThis as Record<symbol, TransformImageFn | null | undefined>)[kTransformImage];
if (cached !== undefined) return cached ?? undefined;

...and store the result back onto globalThis at each exit point instead of the module-scoped _transformImage.

const parsed = parseTransformParams(url.searchParams);
if (!parsed.ok) {
return apiError("VALIDATION_ERROR", parsed.message, 400);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] The comment explicitly says SVGs should be refused, but image/svg+xml starts with "image/", so SVGs pass this check. When no transformer is configured, the original SVG bytes stream through without the CSP sandbox and Content-Disposition: attachment protections that the file/[...key] route applies for SVGs. The condition should match the documented intent:

Suggested change
}
// Only raster images can be transformed. Refuse anything else rather than
// feed it to the binding (SVG/PDF/etc. would error or be unsafe).
if (!source.contentType.startsWith("image/") || source.contentType === "image/svg+xml") {
return apiError("VALIDATION_ERROR", "Source is not a transformable image", 400);
}

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond overlap and removed review/needs-review No maintainer or bot review yet labels Jun 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This feels like it should be being handled a lot more narrowly. Right now it's basically reproducing the whole Astro image pipeline and Cloudflare Astro image service just to switch it so it loads from R2 rather than fetch. It gives us two services running next to each other, each with their own implementation. A better approach could be to implement an Astro image service that works like the existing Astro cloudflare-binding service but reads from R2 rather than fetch.

@ascorbic

Copy link
Copy Markdown
Collaborator

Thanks. Closing this as superseded by #1549

@ascorbic ascorbic closed this Jun 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants