Media — uploaded & static assets
A space has two stores, by role:
- Workspace (
state.*) — your source of truth: code, text, templates.build+publishoperate on it. - Media (
media.*) — durable binary/static assets served by URL: user-uploaded images/video/fonts/binaries.
Media is not part of the source workspace or the bundle. It is addressed by
URL and served by the edge (serveStatic) at the site root.
Serving precedence
For every request to <your-space-slug> the edge tries, in order:
- Bundled assets from the latest
publish(yourpublic/files in Workspace). - Media bucket at
<your-space-slug>{pathname}— public paths directly; a private_*path only if the request carries a valid signed URL (see below). - Your published dynamic worker (if any).
Static (assets + media) is served straight from R2 — the fast, conventional
path. The dynamic worker only runs for requests nothing static answered. So
a space that has never published still serves its uploaded files (a media-hosted
static site serves with no facet), and a published app can reference
uploaded media by URL — /photos/hero.jpg resolves from media unless the
bundle defines it. Routes you want your worker to own must not be shadowed
by a media object at the same path.
Embedding media — use a bare path
Reference an uploaded file by its bare media path — tree.png,
photos/hero.jpg, _private/deck.pdf — wherever you embed it: a markdown note
or a server-rendered page. Don't write the internal admin
route (/<slug>/media/raw?path=…) or hand-build a host URL; the platform turns a
bare path into the right serveable URL for you:
media.url(path)(run_code / templates) → a plain URL for public, a signed one for private;- the admin markdown preview rewrites
the same way.
Public and private resolve identically — both to a space-host URL (private
just carries ?exp=&sig=) — so the same  works in the preview,
on the published site, and in an admin view.
Caching
Public media is served straight from R2 with HTTP caching wired in — you don't configure anything:
Cache-Control: public, max-age=900, stale-while-revalidate=86400— bytes are fresh for 15 minutes, then served instantly from cache while a background revalidation refreshes them (cheap — seeETag). Media lives at a stable but mutable URL (re-uploadinghero.jpgreplaces the bytes at the same path), so it is deliberately notimmutable: a replacement propagates within ~15 min, and an explicit browser reload (which sendsmax-age=0) shows it immediately.ETag(a content fingerprint) on every response. A client that sendsIf-None-Match: <etag>gets304 Not Modifiedwith no body when the file is unchanged — the bytes never leave R2.Rangerequests →206 Partial ContentwithContent-RangeandAccept-Ranges: bytes, so video/audio can seek and large downloads resume.
Bundled assets (serving step 1) get their own caching from the build:
content-hashed filenames (app.a1b2c3d4.js) are cached immutable for a year;
everything else gets public, max-age=0, must-revalidate — ETag/304 still
apply, but there is no 15-minute freshness window and no Range/206 support
(those are media-only).
Private _* media is served by the edge (always via a signed URL, below)
with Cache-Control: private, no-store + X-Robots-Tag: noindex, nofollow
— secret bytes are never shared-cached or indexed.
Image transforms — resize, reformat, reduce bytes
Any image media URL (not .svg) accepts transform query params — the edge
resizes/reformats it on the fly (via Cloudflare Images) instead of you
hand-generating and uploading N resized copies of the same photo:
https://<your-space-slug>.<domain>/photos/hero.jpg?width=800&format=webp&quality=75
| Param | Meaning | Notes |
|---|---|---|
width, height |
target pixel dimensions | clamped server-side to 1–5000px, non-overridable |
fit |
scale-down · contain · pad · squeeze · cover · crop |
how width+height are reconciled with the source aspect ratio |
quality |
1–100 |
lossy formats only (jpeg/webp/avif) |
format |
avif · webp · jpeg · png · auto |
see below — omit it to keep the original format |
dpr |
a multiplier (e.g. 2 for a retina asset) |
multiplies width/height, THEN the 1–5000px clamp is applied |
blur |
1–250 |
Gaussian blur radius |
brightness |
0–10 |
positive multiplier; 0 = fully dark |
contrast |
0–10 |
positive multiplier; 0 = flat gray |
gamma |
0–10 |
positive multiplier |
saturation |
0–10 |
positive multiplier; 0 = grayscale |
sharpen |
0–10 |
sharpening strength |
rotate |
90 · 180 · 270 |
degrees clockwise; 0/other values are dropped (no-op) |
flip |
h · v · hv |
mirror horizontally, vertically, or both |
segment |
foreground |
cut out the detected foreground subject |
gravity |
face · left · right · top · bottom · center · auto · entropy |
crop anchor for fit=cover/crop — named values only, no {x,y} coordinates |
background |
a CSS color (#000, red, rgb(0,0,0), …) |
fills padding from fit=pad or space added by rotate; max 64 chars, shape-validated only |
anim |
true · false |
preserve (default) or strip GIF/WebP animation frames — anim=false gives you the first frame only |
Format is opt-in, not automatic:
- Omit
formatentirely → the original format is preserved (only size/fit/quality change). - Pass a specific
format(webp,avif,jpeg,png) → you get exactly that, no matter what the client asked for. - Pass
format=auto→ the edge negotiates via the request'sAcceptheader (avif → webp → jpeg) and marks the responseVary: Accept, so a browser/CDN cache never cross-serves the wrong format to a client that didn't ask for it.
A request with no recognized transform param is untouched — same bytes, same
behavior as before this feature existed. An unrecognized/invalid value (a bad
fit, a quality over 100) is dropped rather than erroring — you get the
closest valid transform, never a broken request. If the transform itself fails
for any reason, the edge serves the original, untransformed bytes instead
of an error.
Private (_*) media accepts these same params alongside the existing
?exp=&sig= capability — the signature only binds the space/path/expiry, not
the transform, so varying width/format within a signed link's lifetime is
expected and safe.
A combined transform — pad to 800px wide with a black background and a touch of sharpening:
https://<your-space-slug>.<domain>/photos/hero.jpg?width=800&fit=pad&background=%23000&sharpen=1
media.url(path, { transform })
await media.url("hero.jpg", { width: 800, format: "webp" });
// → https://<your-space-slug>.<domain>/hero.jpg?width=800&format=webp
A responsive <img srcset>, minted without hand-building query strings
(the same 320/768/960/1200 breakpoints Cloudflare's own responsive-images
guide recommends):
const srcset = (
await Promise.all(
[320, 768, 960, 1200].map(async (w) => `${await media.url("hero.jpg", { width: w })} ${w}w`),
)
).join(", ");
Private media (_*) — signed URLs, data rooms & paywalls
Media whose path begins with an underscore — _room.pdf, _private/deck.pdf
(the first path segment starts with _) — is never served at a plain
URL. It is always reached through a signed capability URL the edge
verifies and serves directly. Public and private media are addressed the same
way (a space-host URL); private just carries a short-lived ?exp=&sig=.
Signed capability URL
media.url() of a _* path returns a signed, time-limited link — no worker code:
await media.url("_private/deck.pdf"); // signed, ~1h default
await media.url("_private/deck.pdf", { expiresIn: 600 }); // 10-minute capability
The signature binds slug + path + expiry, so a link can't be replayed onto
another file/space or used past its expiry. It is a bearer capability —
anyone holding the unexpired link can read the bytes (like a pre-signed S3 URL),
so it suits share links, data rooms, time-boxed previews. The edge
serves it private, no-store + noindex. expiresIn is silently clamped to
the 7-day cap (a 30-day request comes back as 7 days, no warning). An expired
or bad-signature request is not served by the edge — it falls through to
your worker like any other unmatched request, so the visitor sees whatever your
app answers there (a 404 if there's no worker); don't judge expiry by status
code alone.
A signed URL expires (≤7 days). Embed it in something rendered fresh — a markdown note or a server-rendered page. For a private image on a static published page that must stay live, mint it per request from your worker (below) rather than baking a fixed link into static HTML.
Per-identity gating — decide, then delegate (env.MEDIA.sign)
When access depends on who is asking (a logged-in member, a paid flag) — not
just "holds the link" — let the request fall through to your worker and gate it
there. An unsigned _* request is skipped by the edge and reaches your
fetch(). Decide, then hand back a signed URL and let the edge
serve the bytes — your worker never proxies them:
export class App extends DurableObject {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/_room.pdf") {
// Your gating: session cookie, paid flag, signed token, allow-list…
if (!(await this.isAuthorized(request))) {
return new Response("Members only", { status: 401 });
}
// Authorized → delegate serving to the platform via a signed URL.
const signed = await this.env.MEDIA.sign("_room.pdf", { expiresIn: 600 });
return Response.redirect(signed, 302);
}
return new Response("Not found", { status: 404 });
}
}
env.MEDIA is locked to this space by props SpaceRuntime bakes into the
facet (a ../absolute path is always rejected, so you can never touch another
space's files). Its method is:
sign(path, { expiresIn })→ a signed capability URL for existing private media — the facet decides, the edge serves.url(path, { expiresIn })→ the addressable URL for a path (plain for public, signed for private) — render it straight into<img>/<a>.list(prefix?, { cursor? })→ one page of your media ({ items: { path, size, uploaded, contentType }[], cursor?, truncated }), optionally under a folder prefix.put(path, body, { contentType })→ store bytes (binary only; code-like text is rejected).body= aReadableStream(request.body, streams past the 32 MiB RPC cap), anArrayBuffer/Uint8Array, or a string. Returns{ ok, path, url, private, size, contentType }. Instantly servable; you gate the calling route (env.MEDIAis space-scoped, not user-scoped).remove(path)→ delete one object.
So a facet can build a self-service media library (browse/upload/delete on
its own __manage/media route) and accept audience uploads — all in the same
<your-space-slug>/… namespace as the agent's media.* below, so an object written any way
is visible to the others.
(Reading public media never needs env.MEDIA — it serves by default.)
To author media as the agent, write it the normal way (media.write("_room.pdf", …) in run_code, or the upload page).
media.* inside run_code
async () => {
await media.list(); // [{ path, size, uploaded, contentType }]
await media.list("photos/"); // filter by path prefix
await media.read("notes.txt"); // text content, or null
await media.write("data/site.json", "{}", "application/json"); // → { ok, path }; code-like text (html/css/js) is rejected
await media.remove("old.png");
await media.url("photos/hero.jpg"); // public → https://<your-space-slug>.<domain>/photos/hero.jpg
await media.url("_private/deck.pdf", { expiresIn: 600 }); // private → signed, time-limited URL
await media.url("photos/hero.jpg", { transform: { width: 800, format: "webp" } }); // resized/reformatted
};
media.* operates on the same <your-space-slug>/... namespace the browser upload
flow writes to — so files a user uploads via the upload page are immediately
visible to media.list, and vice-versa. No publish is needed for media
changes; they are live as soon as written.
Uploading file bytes (don't burn tokens)
Never base64 a binary or large file through write_files/run_code — the
bytes would travel through the model as tool arguments. Instead call
get_urls(slug) and use the sessionUpload it returns: PUT the bytes
out-of-band with the supplied uploadUrl + Bearer token (the response even
ships ready-to-run curl examples). Use the uploadUrl verbatim — it points at
the platform apex (e.g. https://<domain>/<slug>/session/upload), not the
space subdomain (a <slug>.<domain> host is routed to the space's own runtime
and won't reach this endpoint):
# media bucket (durable asset, served by URL)
curl -X PUT -H "Authorization: Bearer <token>" \
--data-binary @hero.png \
"<uploadUrl>?dest=media&path=hero.png" \
-H "Content-Type: image/png"
# source Workspace — TEXT source only (then build/publish)
curl -X PUT -H "Authorization: Bearer <token>" \
--data-binary @page.html \
"<uploadUrl>?dest=workspace&path=/public/page.html"
The token is single-space scoped and short-lived (re-call get_urls to
re-mint). The sessionUpload path needs an agent that can run shell commands;
if you can't, point the user at the browser upload page (also in get_urls).
After upload, confirm with media.list() or state.readdir('/').
When to use which
- Binary (images, fonts, video, audio, any non-text) → ALWAYS media, never
the source workspace. Media serves at the site root too (
/favicon.ico,/og.png), so writing binary topublic/is never necessary — and binary blobs bloat the source. The workspace is text source only. - Generated JSON/data that should be live without a build →
media.write. Generated HTML is code-like and rejected by the media gate — it belongs in the workspace (public/) +host.publish(). - App code, components, anything you want bundled →
state.*+host.publish(). - A user "uploaded a logo / photos" → it's already in media;
media.listto discover it,media.urlto reference it from your templates.