Summary
The r2() storage config accepts a publicUrl option, but it has no effect on how images are served to visitors. The Image component and media API endpoints hardcode /_emdash/api/media/file/ instead of consulting the storage adapter's getPublicUrl() method.
This means all media requests go through the Worker (which reads from R2 and proxies the bytes), even when a public R2 custom domain is configured. The Cache-Control: public, max-age=31536000, immutable header mitigates this for repeat visits, but every cache miss still hits Worker CPU/memory to proxy R2 bytes.
Config
storage: r2({
binding: "MEDIA",
publicUrl: "https://media.example.com", // accepted but unused
}),
Where it breaks
1. EmDashImage.astro (lines 58–64) — hardcodes the API route:
function buildLocalImageUrl(img: MediaValue): string {
const storageKey = (img.meta?.storageKey as string) || img.id;
if (storageKey) {
return `/_emdash/api/media/file/${storageKey}`;
}
return "";
}
2. Portable Text Image.astro (line 128) — same hardcoded fallback:
src = asset.url || `/_emdash/api/media/file/${asset._ref}`;
3. Media API endpoints (api/media/[id]/confirm.ts:27, api/media.ts:35) — return hardcoded URLs instead of calling storage.getPublicUrl():
url: `/_emdash/api/media/file/${item.storageKey}`,
Where it works
The R2Storage class correctly implements it:
upload() returns url: this.getPublicUrl(options.key) ✅
getPublicUrl() returns ${publicUrl}/${key} when configured ✅
But nothing downstream uses the upload result's URL or calls getPublicUrl().
Expected behavior
When publicUrl is configured, rendered <img> tags should use the public URL (e.g., https://media.example.com/storageKey.jpg) instead of /_emdash/api/media/file/storageKey.jpg. This would bypass the Worker entirely for media serving.
Suggested fix
Expose the storage's public URL base to the rendering layer — either:
- Make
buildLocalImageUrl() in the Image components aware of the configured publicUrl, or
- Have the media API endpoints call
storage.getPublicUrl(key) so the URL stored in content fields reflects the public URL
Option 2 would also fix existing content on next re-publish/re-save.
Environment
- EmDash 0.1.x on Cloudflare Workers + R2
@emdash-cms/cloudflare storage adapter
- R2 bucket with custom domain configured
Summary
The
r2()storage config accepts apublicUrloption, but it has no effect on how images are served to visitors. The Image component and media API endpoints hardcode/_emdash/api/media/file/instead of consulting the storage adapter'sgetPublicUrl()method.This means all media requests go through the Worker (which reads from R2 and proxies the bytes), even when a public R2 custom domain is configured. The
Cache-Control: public, max-age=31536000, immutableheader mitigates this for repeat visits, but every cache miss still hits Worker CPU/memory to proxy R2 bytes.Config
Where it breaks
1.
EmDashImage.astro(lines 58–64) — hardcodes the API route:2. Portable Text
Image.astro(line 128) — same hardcoded fallback:3. Media API endpoints (
api/media/[id]/confirm.ts:27,api/media.ts:35) — return hardcoded URLs instead of callingstorage.getPublicUrl():Where it works
The
R2Storageclass correctly implements it:upload()returnsurl: this.getPublicUrl(options.key)✅getPublicUrl()returns${publicUrl}/${key}when configured ✅But nothing downstream uses the upload result's URL or calls
getPublicUrl().Expected behavior
When
publicUrlis configured, rendered<img>tags should use the public URL (e.g.,https://media.example.com/storageKey.jpg) instead of/_emdash/api/media/file/storageKey.jpg. This would bypass the Worker entirely for media serving.Suggested fix
Expose the storage's public URL base to the rendering layer — either:
buildLocalImageUrl()in the Image components aware of the configuredpublicUrl, orstorage.getPublicUrl(key)so the URL stored in content fields reflects the public URLOption 2 would also fix existing content on next re-publish/re-save.
Environment
@emdash-cms/cloudflarestorage adapter