Media — uploaded & static assets

A space has two stores, by role:

  • Workspace (state.*) — your source of truth: code, text, templates. build + publish operate 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:

  1. Bundled assets from the latest publish (your public/ files in Workspace).
  2. Media bucket at <your-space-slug>{pathname} — public paths directly; a private _* path only if the request carries a valid signed URL (see below).
  3. 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 pathtree.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 ![](path) the same way.

Public and private resolve identically — both to a space-host URL (private just carries ?exp=&sig=) — so the same ![](photo.png) 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 — see ETag). Media lives at a stable but mutable URL (re-uploading hero.jpg replaces the bytes at the same path), so it is deliberately not immutable: a replacement propagates within ~15 min, and an explicit browser reload (which sends max-age=0) shows it immediately.
  • ETag (a content fingerprint) on every response. A client that sends If-None-Match: <etag> gets 304 Not Modified with no body when the file is unchanged — the bytes never leave R2.
  • Range requests206 Partial Content with Content-Range and Accept-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-revalidateETag/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 1100 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 1250 Gaussian blur radius
brightness 010 positive multiplier; 0 = fully dark
contrast 010 positive multiplier; 0 = flat gray
gamma 010 positive multiplier
saturation 010 positive multiplier; 0 = grayscale
sharpen 010 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 format entirely → 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's Accept header (avif → webp → jpeg) and marks the response Vary: 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 = a ReadableStream (request.body, streams past the 32 MiB RPC cap), an ArrayBuffer/Uint8Array, or a string. Returns { ok, path, url, private, size, contentType }. Instantly servable; you gate the calling route (env.MEDIA is 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 to public/ 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.list to discover it, media.url to reference it from your templates.