feat(media): binding-based image transforms for same-origin media (Access-safe)#1494
feat(media): binding-based image transforms for same-origin media (Access-safe)#1494scottbuscemi wants to merge 2 commits into
Conversation
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 detectedLatest commit: d0bca58 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
Scope checkThis 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. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | d0bca58 | Jun 15 2026, 11:15 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | d0bca58 | Jun 15 2026, 11:14 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | d0bca58 | Jun 15 2026, 11:16 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
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:
-
Module-scope singleton cache violates AGENTS.md. The
_transformImagevariable inmiddleware.tsis a plain module-scopelet. AGENTS.md states: "Module-scope singletons must live onglobalThis. Vite duplicates modules across SSR chunks; a plainlet cache = nullbecomes two variables." This cache should use aSymbol.forkey onglobalThisinstead. -
Transform route comment says SVGs are refused, but the code lets them through. The
startsWith("image/")check passesimage/svg+xml. When no transformer is configured, the SVG is streamed through without the CSP sandbox andContent-Disposition: attachmentprotections 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. |
There was a problem hiding this comment.
[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 plainlet cache = nullbecomes two variables. Use aSymbol.forkey onglobalThis.
Move the cache to globalThis so it is stable across chunk boundaries. For example:
| // 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); | ||
| } |
There was a problem hiding this comment.
[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:
| } | |
| // 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); | |
| } |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
ascorbic
left a comment
There was a problem hiding this comment.
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.
|
Thanks. Closing this as superseded by #1549 |
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
/_imageendpointfetch()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 →/_imagereturns 404. It "works locally" only becauseastro devfetches 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 CloudflareIMAGESbinding). 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 existingastro:assetspath otherwise (external CDN/publicUrlmedia, Node + sharp, or no transformer). Behavior is unchanged whenimagesis not configured.Opt-in wiring on Cloudflare:
Implementation
ImageServiceDescriptor+virtual:emdash/images(mirrors the storage descriptor pattern so core stays portable); transform route handler; portable URL/srcset builders + param validation inmedia/image-transform.ts; the transformer is built in middleware and exposed aslocals.emdash.transformImage.imageBinding()config helper +media/transform-runtime.tsthat uses theIMAGESbinding.Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
New tests:
tests/unit/media/image-transform.test.ts(URL/srcset builders, key safety, param validation) and transform-route handler + injection coverage intests/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.