Caching and releases
ETags and conditional requests, the development and published cache policies, and why an exact version URL can be cached as immutable.
- Availability: Experimental
- Evidence: Read from source
- Reference
ETags and conditional requests
Every successful response carries a strong ETag, the SHA-256 digest of the body, written "sha256-…". Send it back in If-None-Match to revalidate:
| Status | Meaning |
|---|---|
200 |
The resource, with ETag and Cache-Control |
304 |
The tag in If-None-Match is current. ETag and Cache-Control are repeated and there is no body. |
// Revalidate a module with its ETag. A current tag answers 304 with no body.
const request = process.env.MODULE_REQUEST ?? '/m/@example/[email protected]/modules/text?target=browser&format=esm';
const origin = process.env.CDN_ORIGIN ?? process.env.DEV_ORIGIN;
const url = new URL(request, origin);
const first = await fetch(url);
const etag = first.headers.get('etag');
await first.arrayBuffer();
console.log(`first request: ${first.status}, ETag ${etag}`);
const second = await fetch(url, { headers: { 'If-None-Match': etag } });
console.log(`with If-None-Match: ${second.status}`);
console.log(` ETag repeated: ${second.headers.get('etag') === etag}`);
console.log(` Cache-Control: ${second.headers.get('cache-control')}`);
if (first.status !== 200 || second.status !== 304) process.exitCode = 1;The tag identifies bytes, not a build recipe. Two services return the same tag only when they return the same bytes, and a development artifact and a production artifact of one module are different bytes.
Two cache policies
Identities, options, bodies and errors are shared between services. The Cache-Control of a successful response is what services differ in on purpose. The contract names two policies:
| Policy | Cache-Control |
Used by |
|---|---|---|
development |
no-store |
Output that follows its sources. Nothing is stored by a client or an intermediary, and the ETag revalidates every request. |
published |
Exactly one of public and private, with max-age=<seconds>, and optionally immutable. Never no-store or no-cache. |
Output of a published identity whose bytes are retained |
The specification lists max-age=31536000 (one year) and immutable as the values of the published policy:
Cache-Control: public, max-age=31536000, immutablepublicis for open delivery.privateis for a response that depends on the access of the caller; see Private access.immutableis only valid while the service never changes the bytes of the URL. Whether a release may change the bytes of an exact version is a decision of the published service, so a conforming response may omitimmutable.- An error response is never immutable. In particular,
OUTPUT_NOT_AVAILABLEmust not be cached as permanent: the output can be prepared later.
Why exact versions can be immutable
A path names an exact package version, and the query names one output of it. Releases are immutable snapshots: promotion and rollback move an environment from one release to another, they never rewrite the artifacts of a release. A URL with an exact version therefore keeps its bytes, which is what makes long-lived caching safe.
What changes between releases is the resolution: which versions your specifiers map to. Deploying a new release changes the map, not the modules behind existing URLs.
The release prefix
On the host of a released application, every resource of a release is served under a release-scoped prefix, where the number is the sequential number of the release:
/_r/<release number>/index.html the HTML shell of a frontend target
/_r/<release number>/bootstrap.js the script that imports the entry
/_r/<release number>/loader.js SystemJS, in the system loader mode only
/_r/<release number>/resolution.json[?target=…&format=…] the resolution of each target
/_r/<release number>/importmap.json[?target=…&format=…] the same, as an import map relative to itself
/_r/<release number>/m/@example/[email protected]/modules/core/router?target=browser&format=esm/_r/<release number> is a base origin in the sense of the delivery contract: a routed service includes its path prefix in its origin. Below it, the path and the query are exactly the shared ones, so the same relative module URL still applies and nothing about the grammar changes. The relative references that outputs contain, such as ../assets/<path> from a stylesheet, stay inside the release. This base is what a Node process or a Deno program is given; see Strategies per environment.
Every document a release generates (the shell, the import map, the resolution, the bootstrap) addresses a package by its source, as the sources define it: npm unprefixed, another registry as /m/<registry id>/…, a Git repository as /m/git/… and an archive as /m/digest/…. A development server writes the same addresses for the npm and registry packages it serves, so a consumer changes only the base origin between the two. Inside one release a package version comes from one source, so a release base also answers the unprefixed path of a package of another registry, with the same bytes and validator, as a compatibility alias that no generated document names; do not build addresses on it. The shared delivery origin answers only the qualified form for a package of another registry, because there an unprefixed path means npm.
https://<application host>/_r/12/m/@example/app@1.0.0/modules/core/router?target=browser&format=esm
https://<application host>/_r/12- Base origin: the only part you change
/m/- Namespace
@example/app- Package
@1.0.0- Exact version
/modules/- Resource family
core/router- Public module subpath
?target=browser&format=esm- Options
The current alias is read once per navigation
The only mutable address is the navigation itself: / and any navigation path answer the HTML shell of the release the environment binds now. That binding is read once, for the navigation. The shell then names every later resource under its own /_r/<release number>/: stylesheets, the import map, the bootstrap, the loader, and through them every module and asset.
Those requests never consult the binding again. A promotion between the HTML request and a later lazy import therefore cannot mix two releases, and a tab that was opened before a promotion keeps loading the release it started with.
| Response | Cache-Control |
|---|---|
A public resource under /m/… or /_r/<release number>/… |
public, max-age=31536000, immutable |
| A restricted resource of a private application | private, max-age=60, with Vary: Cookie; never public, never immutable |
| The navigation HTML of a public application | public, max-age=0, must-revalidate, with an ETag |
| The navigation HTML of a private application | private, max-age=0, must-revalidate, with Vary: Cookie |
A path under /m, /_r or /_beyond is never a navigation: a missing resource there answers the JSON 404 of the contract, never HTML. Any other path without a file extension receives the shell of the current release, which is what makes deep links work.
Checking a policy in code
import { Policy } from '@beyond-js/artifact-api';
Policy.development.header(); // 'no-store'
Policy.published.header(); // 'public, max-age=31536000, immutable'
Policy.published.header({ visibility: 'private', age: 600 }); // 'private, max-age=600, immutable'
Policy.published.accepts('private, max-age=600'); // true
Policy.published.violation('no-store'); // why the value is not published caching