Media — uploaded & static assets
A space has two stores, by role:
- Workspace (
state.*/git.*) — your source of truth: code, text, templates. Versioned by git.build+deployoperate on it. - Media (
media.*) — durable binary/static assets served by URL: user-uploaded images/video/fonts, and any pre-existing static site (HTML/CSS/JS) that was uploaded before the workspace existed.
Media is not part of the git workspace or the bundle. It is addressed by URL and served by the supervisor at the site root.
Serving precedence
For every request to <your-space-slug> the supervisor tries, in order:
- Bundled assets from the latest
deploy(yourpublic/files). - 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 deployed 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 with no deploy still serves its uploaded files (pre-existing static
sites keep working with no migration), and a deployed 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,
a server-rendered page, a custom admin view. 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; - the media picker writes a bare path into your field (and supplies a signed preview URL for private picks).
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 deployed 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 revalidates like media.
Private _* media is served by the supervisor (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.
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 supervisor
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 supervisor
serves it private, no-store + noindex.
A signed URL expires (≤7 days). Embed it in something rendered fresh — a markdown note, a server-rendered page, a custom admin view. For a private image on a static deployed page that must stay live, mint it per request from your worker (below) rather than baking a fixed link into committed 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 supervisor and reaches your
fetch(). Decide, then hand back a signed URL and let the supervisor
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 supervisor 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 the supervisor bakes in (a
../absolute path is always rejected, so you can never touch another space's
files). It has two methods:
sign(path, { expiresIn })→ a signed capability URL for existing media (above) — the facet decides, the supervisor serves.put(path, bytes, { contentType, tags, context })→ store new bytes, returning{ path, url, private }(below).
(Reading public media never needs env.MEDIA — it serves by default.)
Accepting user uploads — env.MEDIA.put
When your facet's own UI takes a file from an end user (an avatar, a submitted
PDF, a generated export), stream it straight to media with put — no deploy,
no sandbox. Visibility is just the path you choose: a normal path is public; a
_-prefixed path is private (serves only via a signed URL). Your user-facing
logic decides which, per upload:
export class App extends DurableObject {
async fetch(request) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/avatar") {
// YOUR job first: authenticate the uploader + validate type/size.
const { path } = await this.env.MEDIA.put(
`avatars/${userId}.png`, // public path → served by default
request.body, // a ReadableStream; bytes never touch the model
{ contentType: request.headers.get("content-type") ?? "image/png" },
);
await this.saveAvatarPath(userId, path); // store the PATH, not a URL
return new Response("ok");
}
if (request.method === "POST" && url.pathname === "/contract") {
const { path } = await this.env.MEDIA.put(`_docs/${userId}/contract.pdf`,
request.body, { contentType: "application/pdf" }); // `_` prefix → private
await this.saveContractPath(userId, path); // serve later via env.MEDIA.sign(path)
return new Response("ok");
}
return new Response("Not found", { status: 404 });
}
}
put overwrites by key and does not cap size or authenticate the
uploader — those are your facet's job, since the bytes come from its users.
Persist the returned path (re-sign() private media on demand rather than
storing the expiring URL); render public media as /<path>.
To author media as the agent (not from a live request), 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");
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
};
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 deploy 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"
# git-backed Workspace (then commit/build/deploy)
curl -X PUT -H "Authorization: Bearer <token>" \
--data-binary @hero.png \
"<uploadUrl>?dest=workspace&path=/public/hero.png"
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 git workspace. Media serves at the site root too (
/favicon.ico,/og.png), so committing binary topublic/is never necessary — and binary blobs bloat git with un-diffable history. The workspace is text source only. - Generated HTML/JSON that should be live without a build →
media.write. - App code, components, anything you want versioned & bundled →
state.*git+deploy.
- A user "uploaded a logo / photos" → it's already in media;
media.listto discover it,media.urlto reference it from your templates.