Same-origin assets (assets)#
Left alone, a rendered page would reference the store's files (images, css/js)
by their absolute storage.googleapis.com URL, uploaded with a 1-year
immutable cache-control. That's fine until someone edits an asset in
place: the old bytes serve from the CDN for up to a year, and the render
server never learns the object changed.
So by default (assets.serve is on; pass {serve: false} to opt out) the
site instead:
- rewrites every one of this store's storage URLs in the finished HTML to a
same-origin path (
{basePath}{pathPrefix}/{storage_path}, default prefix/assets) — file-node fields, favicon<link>s, and literal URLs baked into template HTML alike (the rewrite runs on the whole page, not just the flattener, and before the render-cache write, so cached HTML already carries the new URLs).og:image/twitter:imagemetas stay absolute (scrapers can't resolve site-relative paths) — rewritten ontositeBaseUrlwhen set, else left on the storage URL; - serves those paths from a byte-budgeted in-memory cache (misses fetch
from Storage and cache; over-budget objects stream through uncached; every
response carries a strong ETag for cheap
304revalidation). An object abovemaxObjectBytes(16 MiB — videos, large downloads; uploads go up to 1 GiB) is never relayed:serveAssetanswers a 302 to the storage URL (orredirectBase), decided from the response'scontent-lengthbefore the body is read, so it never touches the server's memory or cache — and GCS servesRangerequests (video seeking) natively; - invalidates on the new
fileshook — mountPOST {pathPrefix-less}/files/**at your receiver and callsite.receiveFilesHook(storagePath, body, headers). A file push evicts (and, withwarmOnHook, re-fetches) its one object; an archive push clears the asset cache (its extracted objects share a storage prefix a point-lookup cache can't target individually).
const site = new WebSite({ ..., assets: {preload: 'eager', warmOnHook: true} });
app.get('/assets/*', async (req, res) => {
const a = await site.serveAsset(req.path.replace(/^\/assets\//, ''));
if (a.status === 302 && a.location) return res.redirect(302, a.location);
if (a.status !== 200 || !a.body) return res.status(a.status).end();
if (a.etag && req.get('if-none-match') === a.etag) return res.status(304).set('etag', a.etag).end();
res.type(a.contentType ?? 'application/octet-stream').set('cache-control', a.cacheControl!);
if (a.etag) res.set('etag', a.etag);
res.send(a.body);
});
app.post('/files/*', express.json(), async (req, res) => {
const r = await site.receiveFilesHook(req.path.replace(/^\/files\//, ''), req.body, flattenHeaders(req.headers));
res.status(r.ok ? 200 : 401).json(r);
});serveAsset only resolves this store's own store/{sid}/… objects — it is not
an open storage proxy. WebPreviewServer wires both routes for you when you
pass assets to it (GET {pathPrefix}/** and POST /files/**).
assets.* |
default | notes |
|---|---|---|
serve |
true |
Master switch for the rewrite + serving; false opts out. |
pathPrefix |
/assets |
URL prefix the objects are served under. |
preload |
lazy |
eager walks the files tree on site creation and warms the cache up to maxBytes (skips logged; objects above maxObjectBytes are skipped by their doc size, never fetched). |
maxBytes |
32 MiB | Byte budget of the default ByteBudgetCache (LRU by decoded bytes). Ignored when the assets cache slot is injected. |
maxObjectBytes |
16 MiB (ASSET_MAX_OBJECT_BYTES) |
Largest single object the site serves itself; bigger ones answer 302 to storage / redirectBase. A chunked response without content-length is read with a running count and abandoned at the cap (the exported readCapped(resp, cap) helper). |
ttlMs |
60 min | Cache-entry backstop for a lost/unconfigured files hook. |
cacheControl |
public, max-age=300 |
Sent with served assets; the ETag makes revalidation cheap. |
warmOnHook |
false |
Re-fetch an asset right after its hook eviction so the next request is a hit. |
The default asset backend is the exported ByteBudgetCache — a
byte-accounted LRU (MemoryCache's entry-count bound is unsafe for file
bodies), holding StoredAsset values ({body: base64, contentType?, size, etag?, contentDisposition?} — JSON-round-trippable, so a shared KV
backend can hold the slot). contentDisposition is the object's own header
as Storage served it (uploads carry inline; filename="…", the uploaded
filename) and is forwarded on /assets/**, so a visitor saving a PDF gets
Urbanity-ESG-report-2024.pdf, not the opaque object id; the disk sidecar
persists it and a pre-field sidecar simply decodes without it. Inject your own via the caches.assets slot; an asset larger than the
whole budget is never stored (streamed through instead).
In
livemode the rewrite and the cache stay off (the admin preview keeps direct storage URLs, consistent with every other cache slot) — butserveAssetstill answers/assets/**requests with a cache-free 302 to the storage URL, so site-relative asset links (the canonicalsetup/web.headTagsform) resolve in live previews too.
Same-origin CSS/JS (serveCode)#
In every non-live site, the design-system / shell / component-fragment /
template CSS and JS leave the page HTML: instead of inline <style> /
<script> blocks, rendered pages carry a handful of stable, store-qualified
links that browsers can cache —
| URL | source |
|---|---|
/store/{sid}/css/design.css |
setup/design (meta cache) |
/store/{sid}/css/template/{schemaId}/{version}.css |
that template version (template cache) |
/store/{sid}/css/element/{id}.css |
the element doc (meta cache) |
/store/{sid}/css/elements.css |
setup/elements.css — the shared element-kit stylesheet (meta cache) |
/store/{sid}/js/template/{schemaId}/{version}.js |
template JS (template cache) |
/store/{sid}/js/element/{id}.js |
element JS (meta cache) |
/store/{sid}/js/elements.js |
setup/elements.js — the shared element-kit script (meta cache) |
Every link a rendered page carries ends in ?v={content hash} — a
64-bit FNV-1a of the body, computed by cssHrefOf / jsHrefOf in
compose.ts from the body the renderer already holds (contentHash() is
exported; it is sync and browser-safe, the module is bundled into the admin
SPA). The cache-control follows the URL, not the source:
| URL form | cache-control | why |
|---|---|---|
hashed (…?v=…) |
public, max-age=31536000, immutable |
content-addressed — a body edit re-renders the page onto a new URL |
bare (no v) |
public, max-age=300 + ETag |
may point at a body that changes in place |
This is what keeps a browser from rendering today's page against yesterday's
stylesheet: a template version is not immutable (Code Studio edits it in
place and the templates hook event evicts the render cache), and the design
system / elements have no version at all. Before 2026-09-12 the template URLs
were immutable on their version number alone, so a phone that had visited
the site kept the stale CSS for up to a year while an anonymous tab looked
fine. The hash is never verified server-side — it is a cache key only; the
path stays stable (no hash in the file name) so the route table and any
proxy asset rules don't move.
WebSite.serveCode(path) answers them by composing the body from the caches
the renderer already keeps warm (no new cache slot — a cold replica rebuilds on
miss; schemaId may be multi-segment, the last path segment is always
{version}.{ext}). The embedded sid must match the site.
The per-page window.DROP_BASE/DROP_LOCALE/DROP_ROUTES bootstrap stays inline,
and so does window.DROP_DATA (the page document with its resolved
references) — but only on a page a script of which mentions DROP_DATA (the
template JS, element / module / shell JS, headTags / footerTags; compose.ts
pageNeedsData). Every other page leaves it out, often the bulk of its HTML.
setup/web.pageData: 'always' restores it everywhere (the element library's
Storage asset files are linked by URL, so their text is not scanned).
live sites keep today's fully-inline pages (and still answer serveCode
requests, no-store).
WebPreviewServer routes GET|HEAD /store/{sid}/css|js/** for you; a thin
WebSite consumer mounts it like the instore example:
app.get(/^\/store\/[^/]+\/(?:css|js)\/.+/, async (req, res) => {
const c = await site.serveCode(req.originalUrl); // NOT req.path — the `?v=` must reach serveCode
if (c.status !== 200 || c.body === undefined) return res.status(c.status).end();
if (c.etag && req.get('if-none-match') === c.etag) return res.status(304).set('etag', c.etag).end();
res.set('content-type', c.contentType!).set('cache-control', c.cacheControl!);
if (c.etag) res.set('etag', c.etag);
res.send(c.body);
});setup/web.httpHeaders — admin-configured response headers#
The setup doc may carry httpHeaders: [{key, value}]. WebSite.getPage()
returns them on WebPageResult.headers whenever the setup doc was readable
— also on 404s — and WebPreviewServer applies them to every rendered
HTML response. A consumer driving a bare WebSite must honor the same
contract:
- Apply after the defaults, so the admin can override
cache-control(and friends) but your transport defaults still exist. - Never apply the hop-by-hop/transport denylist:
content-length,transfer-encoding,connection,keep-alive,upgrade,content-encoding,te,trailer,host. Keys must also match^[a-z0-9_-]+$(case-insensitive); values that failsetHeader(CR/LF, …) are skipped silently.