inmake DOCS
Docs / Server / Assets & code

Assets & code

Same-origin files, CSS/JS and admin-configured response headers.

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:

  1. 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:image metas stay absolute (scrapers can't resolve site-relative paths) — rewritten onto siteBaseUrl when set, else left on the storage URL;
  2. 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 304 revalidation). An object above maxObjectBytes (16 MiB — videos, large downloads; uploads go up to 1 GiB) is never relayed: serveAsset answers a 302 to the storage URL (or redirectBase), decided from the response's content-length before the body is read, so it never touches the server's memory or cache — and GCS serves Range requests (video seeking) natively;
  3. invalidates on the new files hook — mount POST {pathPrefix-less}/files/** at your receiver and call site.receiveFilesHook(storagePath, body, headers). A file push evicts (and, with warmOnHook, 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 live mode the rewrite and the cache stay off (the admin preview keeps direct storage URLs, consistent with every other cache slot) — but serveAsset still answers /assets/** requests with a cache-free 302 to the storage URL, so site-relative asset links (the canonical setup/web.headTags form) 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 fail setHeader (CR/LF, …) are skipped silently.