Replies: 1 comment
-
|
This direction is great for us — we're hit by exactly the Access / One question on the lookup path: does the wrapped (local-service) endpoint resolve a media key purely from Why it matters for us: in one of our plugins (a community photo-submission widget) we write uploads directly to the R2 bucket via the Worker binding and never create a DB media row — because Two related notes if the storage-adapter path is the intent:
Happy to test against our R2 setup once there's a branch. Thanks for tackling this. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
RFC: Storage-backed image optimization (Access-safe, per-service strategy)
Summary
EmDash already routes locally/R2-stored media through Astro's image service for responsive
srcsets (#1438). That optimization is silently broken on Cloudflare: behind Cloudflare Access (or withglobal_fetch_strictly_public), Astro's image endpoint mustfetch()the media source over HTTP, and that self-referential subrequest gets an Access login redirect instead of image bytes — surfacing as a 404 from/_image.This RFC proposes fixing it by choosing the correct Astro image strategy per service type rather than applying one strategy everywhere. Astro image services are either local (sharp, Cloudflare's binding service — they load source bytes through an endpoint and transform server-side) or external (Netlify, Vercel — they emit a URL that the platform's edge optimizer fetches). For local services, EmDash wraps the image endpoint so source bytes come straight from the storage adapter, never over HTTP. For external services, EmDash does nothing — the edge optimizer already fetches the (public) media URL. The result is one Astro pipeline, no parallel transform route, no per-image component branching, and the Access 404 disappears on the one platform where it actually occurs.
This supersedes the binding-only approach in #1494, reusing its Cloudflare
IMAGES-binding transformer while discarding its parallel route and component-level branching.Example
Before (
Image.astro/EmDashImage.astro, today): the component absolutizes the media URL, authorizes the origin viaimage.remotePatterns, and callsgetImage. On Cloudflare behind Access this 404s; #1494 added a second code path (a parallel/_emdash/api/media/transform/{key}route plus component branching) to work around it.After: the component always hands the relative internal media URL to
<Image>/getImage— no absolutize, no branching:What happens to that request is decided once, at config time, by the configured image service:
image.endpointcloudflare-bindingimage.endpointenv.IMAGESUser-facing config is unchanged: the adapter's
imageService, Astro'simageblock, and<Image>props own all behaviour. There is no new EmDash config field.Background & Motivation
How Astro loads image source bytes
A local image service (sharp; Cloudflare's
cloudflare-bindingservice) routes through an endpoint.baseService.getURLbuilds the endpoint URL fromimageConfig.endpoint.route(astro/assets/services/service.ts), e.g./_image?href=<src>&w=…. The endpoint then loads the source and runs the transform.Astro's generic endpoint (
astro/assets/endpoint/generic.ts) loads bytes like this:For a relative (same-origin)
srcit forwards the request headers, including the Access cookie. For an absolutesrcit strips them.EmDash's responsive path (
media/responsive.ts) deliberately absolutizes the media URL (toAbsoluteMediaUrl) and authorizes the origin viaimage.remotePatterns, because that was the recipe for getting same-origin media optimized. On Cloudflare that absolutize step flips Astro into the no-cookie remote branch → the loopback subrequest carries no Access cookie → Access returns a login redirect →/_image404s. The bug is applying the external strategy (absolutize + remotePatterns) to a local-service platform.Note that even keeping the URL relative (so the cookie rides along) is not a robust fix: a Worker fetching its own origin is a subrequest blocked by
global_fetch_strictly_public, and edge self-fetches do not reliably carry Access cookies. The durable answer is to not fetch over HTTP at all — read bytes from the storage adapter.How the four target services actually behave
src; sharp transforms. Works today via loopback (no Access on Node), but pays an HTTP round-trip.cloudflare-binding(adapter default): local. The workerd service'stransformis a no-op passthrough; the real resize happens in the endpoint (image-transform-endpoint.tscallsenv.IMAGESdirectly, loading local bytes viaenv.ASSETS.fetch). EmDash media is not inenv.ASSETS, so the adapter's endpoint can't see it — and the absolutize fallback hits the Access wall. Broken today.ExternalImageService).getURL→/.netlify/images?url=<src>; the Netlify edge fetches the URL. No endpoint involved.getURL→/_vercel/image?url=<src>; the Vercel edge fetches the URL. No endpoint involved.External optimizers fetch the source over HTTP from the deployment. With no Access in play (Access is Cloudflare-specific), that fetch of a public EmDash media URL succeeds. They cannot reach private media — and EmDash cannot help them, because there is no endpoint to hook.
Integration ordering
runHookConfigSetupdoessettings.config.integrations.unshift(settings.config.adapter)— the adapter'sconfig:setupruns first, EmDash's runs after, andupdateConfigdeep-merges. EmDash can therefore read the resolvedimage.service.entrypointand overrideimage.endpointdeterministically.Goals
srcseton sharp and Cloudflarecloudflare-binding, including when the site is behind Cloudflare Access orglobal_fetch_strictly_public.<Image>/getImagefor every image, with Astro generating thesrcset. No parallel transform route, no per-image component branching.src/assets, allowed remote) behave exactly as the platform's stock endpoint produces them.Non-Goals
<img>served from storage.imageService: "cloudflare",/cdn-cgi/image). It is endpoint-less and edge-fetches the source, so it cannot reach private media; out of scope here.image.serviceto Cloudinary). EmDash defers to them untouched (unoptimized<img>fallback for EmDash media). The supported way to use such a CDN is an EmDash media provider, which bypasses this path entirely.mediaProviders). It already resolves viagetEmbed/getSrcand never touchesastro:assets.Prior Art
srcsetgeneration for EmDash media viaastro:assets. This RFC fixes the platform where that feature silently fails./_emdash/api/media/transform/{key}route reading bytes from storage and resizing with theIMAGESbinding, selected by component-level branching. This RFC keeps feat(media): binding-based image transforms for same-origin media (Access-safe) #1494'sIMAGES-binding transformer (packages/cloudflare/src/media/transform-runtime.ts) and its key-safety validation, but replaces the parallel route + branching with animage.endpointwrapper so Astro's own pipeline drives everything.@astrojs/cloudflareimage-transform-endpoint.tsis the model for the local Cloudflare endpoint: load bytes by binding (env.ASSETS.fetchthere; the storage adapter here), transform withenv.IMAGES, cache immutable. We mirror its structure and delegate non-EmDash hrefs back to it.Detailed Design
1. Service-type classification
EmDash classifies the resolved
image.service.entrypoint(read inconfig:setup, after the adapter) against a small known-entrypoint map:astro/assets/services/sharpastro/assets/services/noop(passthrough)@astrojs/cloudflare/image-service-workerd@astrojs/netlify/image-service@astrojs/vercel/build-image-service(and the dev variant)@astrojs/cloudflare/image-service(/cdn-cgi/image)<img>(unknown).A static map (not runtime sniffing of an imported module) keeps
config:setupcheap and the supported matrix explicit. New adapter entrypoints are a one-line map addition. The map errs safe: an unrecognized service is never wrapped.2. The EmDash image endpoint
EmDash sets
image.endpoint.entrypointto a platform-specific module, leavingimage.endpoint.routeat the default (/_image). It does this only when (a) the service is local and (b) the currentimage.endpoint.entrypointis a known stock endpoint (astro/assets/endpoint/generic, the Node endpoint, or@astrojs/cloudflare/image-transform-endpoint). If the user configured a custom endpoint, EmDash backs off and logs a warning — their pipeline is their responsibility (they may call an exported EmDash helper if they want storage-backed loading).The endpoint module:
matchInternalMediaKeystripsINTERNAL_MEDIA_PREFIXand validates the key with the existingSAFE_STORAGE_KEY/isSafeTransformKeyregex (no slashes, traversal, query/fragment characters).transformFromStoragecallsemdash.storage.download(key)(the same call the/file/{key}route makes), checks the source isimage/*, then runs the transform:env.IMAGES(reusing feat(media): binding-based image transforms for same-origin media (Access-safe) #1494'stransform-runtime.ts). The binding name is the adapter's, exposed viaglobalThis.__ASTRO_IMAGES_BINDING_NAME(defaultIMAGES).getConfiguredImageService().transform(bytes, opts, imageConfig)(sharp, or whatever local service is configured).Cache-Control: public, max-age=31536000, immutable,X-Content-Type-Options: nosniff.delegateToStockEndpointforwards the whole request to the platform's stock endpointGET(@astrojs/cloudflare/image-transform-endpointon CF,astro/assets/endpoint/genericon Node), so bundled and allowed-remote images get byte-identical stock behaviour.The endpoint entrypoint lives where its platform deps are importable: a Node variant in
@emdash-cms/core, the Cloudflare variant in@emdash-cms/cloudflare(alongside the existing transformer). EmDash core references the Cloudflare entrypoint by string when it detects the Cloudflare adapter — the same indirection #1494 already uses for the transformer descriptor, so core stays portable.3. Component unification
Image.astroandEmDashImage.astrostop branching on platform/service. For local/R2 media they emit the relative internal URL (/_emdash/api/media/file/{key}) and call<Image>/getImagewith knownwidth/height/widths. Astro'sgetSrcSetgenerates the candidates.getURL→/_image?href=/_emdash/api/media/file/{key}&w=…; the EmDash endpoint loads bytes from storage.getURL→/.netlify/images?url=/_emdash/api/media/file/{key}&w=…(or/_vercel/image?…); the edge fetches the relative same-origin URL.Removed:
toAbsoluteMediaUrl,buildResponsiveImage(the absolutize/remote-pattern dance), and #1494'sbuildTransformUrl/buildTransformSrcset/buildTransformedImageplus the parallel route.responsiveWidths/responsiveSizesare retained.Because same-origin media is now passed relative, the
image.remotePatternsentries EmDash injects for media (buildImageRemotePatterns) are no longer required for the media path. They are retained only if still needed forpublicUrl-hosted (CDN-origin) media.4. Provider and
publicUrlmedia (unchanged)mediaProviders) is handled by the provider branch in the components and never reachesastro:assets.publicUrl(R2 custom domain, S3 CDN) resolves to a genuinely public external URL. On local services it falls throughdelegateToStockEndpoint(allowed-remote fetch); on external services the edge fetches it. Either way it is not an EmDash media key and is not storage-loaded.Security Model
hrefis accepted only when it matchesINTERNAL_MEDIA_PREFIX+ a key passingSAFE_STORAGE_KEY(/^[A-Za-z0-9._-]+$/). No slashes,..,?,#, or%, so the key cannot traverse or reroute on the storage backend. Identical to the existing/file/{key}and feat(media): binding-based image transforms for same-origin media (Access-safe) #1494 route guards.storage.download). The delegate branch reuses the stock endpoint's existing remote-allow checks (isRemoteAllowed) unchanged.image/*sources are transformed; anything else is refused (SVG/PDF/etc. are never fed to the binding). Responses carryX-Content-Type-Options: nosniff.Testing Strategy
matchInternalMediaKeyaccept/reject (traversal, query chars, bare id vs key); transform param validation; "no transformer → stream original" fallback.describeEachDialect) — endpoint resolves a known key to bytes via the storage adapter and returns a transformed/streamed image; non-EmDash hrefs delegate.EMDASH_E2E_TARGET=cloudflareagainste2e/fixture-cloudflare: render a page with EmDash media and assert a realsrcset(multiple distinct candidate URLs) and a 200 (not a login redirect) for a transform request. A regression guard for the original Access 404 — ideally exercised with a simulated gate or theglobal_fetch_strictly_publicflag, since stock e2e cannot reproduce Access.pnpm query-countsto confirm no added per-render queries.Drawbacks
image.endpointfor local services. The delegate path keeps non-EmDash images byte-identical, but it is a larger footprint than feat(media): binding-based image transforms for same-origin media (Access-safe) #1494's opt-in side route. Mitigated by the known-stock-endpoint guard and the custom-endpoint back-off.Alternatives
srcset/breakpoint/format logic, and special-cases components. Rejected in favour of folding back into Astro's single pipeline.global_fetch_strictly_publicand edge self-fetches don't reliably carry the cookie. Fragile; rejected.image.serviceentirely with an EmDash local service. Heavier; fights the adapters (which set the service) and changes the transform backend for all images. Wrapping only the endpoint is the minimal change that keeps the adapter's service authoritative.Adoption Strategy
Additive and default-on. No migration: it makes the existing #1438 behaviour correct on Cloudflare and removes an HTTP round-trip on Node, while leaving Netlify/Vercel and non-EmDash images unchanged. An
emdash({ images: false })opt-out skips the endpoint wrap for anyone running their own image pipeline. EmDash is pre-1.0; this ships as a fix with a changeset describing the observable effect (EmDash media now produces a responsivesrcseton Cloudflare behind Access).Implementation Plan
config:setupwiring; ship the Node endpoint (storage +getConfiguredImageService().transform+ delegate to generic); switchImage.astro/EmDashImage.astroto the uniform relative-URL path; removetoAbsoluteMediaUrl/buildResponsiveImage. Tests for the Node/local path.@emdash-cms/cloudflareendpoint reusingtransform-runtime.ts/IMAGES; wire entrypoint selection on adapter detection; e2e onfixture-cloudflare.buildTransformUrl/buildTransformSrcset/buildTransformedImage; reconcileimage.remotePatternsinjection to thepublicUrl-only case. Changeset.Unresolved Questions
cloudflare-bindingwhen the Cloudflare adapter is detected with the external service, or just warn and fall back to unoptimized? (Out of scope to fix, but the diagnostic matters.)image.remotePatternsinjection at all once same-origin media is relative, or scope it strictly topublicUrlmedia.image.endpoint.routestays/_image(wrapping the stock route) vs a dedicated EmDash route —/_imageis simplest and matches the adapter; confirm no conflict with the delegate.Future Possibilities
image.endpoint.blurhash/dominant-color placeholders from stored metadata in the same request family.Beta Was this translation helpful? Give feedback.
All reactions