Skip to content

Commit a55fdc4

Browse files
fix(cli): console static plugin caches hashed assets immutable, stable-named files short-lived with a content etag (#22395)
Fixes #22237 Clause-②: no ## What changed `createConsoleStaticPlugin` (`packages/cli/src/utils/console.ts`) answered every existing non-HTML file under the console dist with a `content-type` and nothing else. With no lifetime and no validator, a browser could reuse no chunk, so every self-hosted console load downloaded the whole SPA again. The static-file arm now sends one of three answers: | file | `cache-control` | validator | | --- | --- | --- | | content-hashed build output: directly under `assets/`, named `NAME-HASH.EXT`, where HASH is 8 characters of rollup's base64url alphabet with at least one uppercase letter, digit or `_` | `public, max-age=31536000, immutable` | none (never revalidated) | | every other non-HTML file | `public, max-age=300` | strong `etag` computed from the bytes (sha256); a matching `If-None-Match` (strong, weak, in a list, or `*`) gets `304` with no body | | HTML: the shell, a direct `index.html`, the SPA fallback | unchanged (none) | unchanged (none) | This follows the triage direction (comment 6056523960): immutable only for Vite hash-shaped names; the shell and the SPA fallback stay revalidated; the stable-named maplibre files get a short lifetime plus a validator. A missing `assets/` file is still a plain 404, with no lifetime. ## The hash shape, measured, not recalled I did not read the gate in cloud#2694; it is outside this session's scope. I derived the shape from the published `@objectstack/console@17.7.0` tarball (`npm pack`, 2471 files): - `dist/assets/` holds 2457 files: 2451 `.js`, 4 `.css` and 2 `.mjs`, with no subdirectories and no `.map` files. - The shape `NAME-HASH.EXT` (8-character base64url HASH) matches **2455 of 2457**. The 2 it excludes are `maplibre-gl-worker.mjs` and `maplibre-gl-shared.mjs`, objectui's stable-named copies (`scripts/vite-maplibre-worker.ts`). **No other stable-named file is in `assets/`.** - The added condition (the hash contains an uppercase letter, digit or `_`) still matches all 2455. It exists to reject eight-letter lowercase words after a dash, such as `pdf-renderer.js`, which match the shape alone. The closest a real hash came to failing it is `vendor-icon-diamond-percent-ekt_smur.js`, where `_` is the only qualifying character. A real hash that fails it would only be revalidated like a stable file. - The root of the dist holds stable `public/` copies (`favicon.svg`, `logo.svg`, `vite.svg`, `manifest.json`, `sdui.manifest.json`, `chunk-membership.json`, `eager-closure.json`, and two dotfiles). They now get the short-lifetime-plus-etag answer. Before this change they had no caching headers at all. ## Why the validator is computed from the content, not the stat Zone 3 of the dispatch suggested `etag` or `last-modified` from the file's stat. I measured it on the same tarball and did not use it. `npm install` stamps every file with the install time, but plain `tar` extraction of that tarball keeps npm's fixed pack mtime, `1985-10-26 08:15:00`, on every one of its 2471 entries. A dist laid down that way gives two releases' different `maplibre-gl-worker.mjs` the same mtime, so a stat validator would answer `304` for bytes the browser has never seen. The sha256 of the bytes has no such dependency. It costs one hash per request, and only on the handful of stable-named files. Hashed chunks carry no validator because they are never revalidated. ## Pins (`packages/cli/src/utils/console.asset-cache.test.ts`, 26 cases) These tests run the real plugin on a real `HonoHttpServer` app, against fixture dists that use real hashed names from the published dist. - 8 hashed names, chosen for the shapes a name rule can get wrong (`-` and `_` inside the hash, a hash whose only qualifying character is `_`, a NAME ending in an uppercase segment), each carry exactly `public, max-age=31536000, immutable`. A missing hashed asset is a 404 with no lifetime. - The two maplibre files, `favicon.svg`, `manifest.json` and 4 edge lookalikes each carry no `immutable`, a `max-age` of at most 300, and a quoted `etag`. The lookalikes are `assets/pdf-renderer.js`, `assets/maplibre-gl-worker-dev.mjs`, a hash-shaped name at the root, and a hash-shaped name below `assets/nested/`. - Revalidation: `If-None-Match` with the file's own tag, `W/` plus the tag, a list holding it, or `*` gets `304` with an empty body and the same `etag` and `cache-control`. Another tag gets `200` with the bytes. **New bytes of the same length with the same 1985 mtime get a new tag**, and the old tag gets `200`. - HTML: `/_console/`, `/_console/index.html` and a deep SPA route each get `text/html` with no lifetime. ## Ablation (one-shot; no permanent test file) Each leg was committed first. The mutation went through `scripts/ablation-replace.mjs` in wrap mode, which checks that the anchor hit exactly once, that the blob changed, and that the file was restored to the `HEAD` blob with an empty `git diff HEAD`. The leg ran the test file under the verify lock, inside a `trap` restore. The test imports `./console.js` relative to `src/`, so the subject resolves to source, not `dist/`, and no rebuild or dist preflight applies. | leg | mutation | result | | --- | --- | --- | | A1, the card's ablation | drop `'cache-control': HASHED_ASSET_CACHE_CONTROL` from the hashed answer | **8 failed / 18 passed**: exactly the 8 hashed pins | | A2, the blanket rule the card warns about | every `assets/` file judged hashed | **10 failed / 16 passed**: the 5 `assets/` stable and lookalike pins, the 4 `304` cases, and the new-bytes case | | A3 | drop the character condition | **1 failed / 25 passed**: `assets/pdf-renderer.js` | | A4 | etag computed from the byte length instead of the bytes | **1 failed / 25 passed**: the same-size, same-mtime replacement | All four restores were proven: blob `66279f318c21` equals `HEAD`, and `git diff HEAD` is empty. ## Verification (all at `174339b9`, the final commit) - New file: `vitest run src/utils/console.asset-cache.test.ts`, **26 passed**. - `@objectstack/cli` `unit` layer (`vitest run --project unit`): 269 of 271 files passed, with 3965 tests passed and 29 skipped. The 2 remaining files, `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts`, refused only because `packages/cli` was not built yet. After building `packages/cli`, the same 2 files passed 29 of 29. The `integration` layer is declared to CI: the diff reaches no spawn entry or kernel boot. - `pnpm --filter @objectstack/cli typecheck` exits 0 (`tsc --noEmit` compiles the new test, confirmed with `--listFilesOnly`; `check:test-typecheck` OK). - Full `pnpm lint` exits 0. This is not a narrowed run. - Gate union: `dispatch-gates --commands` derived **65** families from the 3 changed paths, the same list as the dispatch lead. A union run after the final commit, with the workspace built, gives **65 of 65 exit 0**. `dispatch-gates --ran` with the exit codes recorded reports: `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. ## Acceptance notes - **Upgrade window.** For up to 5 minutes after an upgrade, a browser that loaded the console just before it can pair the new hashed `maplibre-gl-HASH.js` with the previous release's cached `maplibre-gl-worker.mjs` (maplibre loads the worker as a sibling URL of its own chunk). This window is what "short lifetime" buys. It is bounded and self-healing. `no-cache` would close it at the cost of one `304` round trip per stable file per load. - **`/runtime/assets/:filename`** (`createRuntimeAssetsPlugin`) still sends `public, max-age=3600` with no validator. It is not this card's route, and it is unchanged. If it ever gains a validator, `contentEtag` and `ifNoneMatchHits` are the helpers to share. That is a note only. - **Every existing `.html` file** under the dist is answered with `index.html`'s rewritten content, not its own. The measured dist has only `index.html`, so nothing observable changes. This is an observation; nothing was filed. - **Hosted ObjectOS** keeps its edge cache in the environment Worker. Nothing here changes it, and this change gives self-hosted deployments the same reuse at the origin. - **Files touched**: exactly the claim's surface (`console.ts` static-file arm and its helpers, one new test beside it, one changeset). `formatConsoleDistMissingWarning`, `resolveConsolePath`, `serve.ts` and the `--ui` help text are untouched, for #22225, which comes after this card. No new export, so the `published-subpath-console` export partition is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac8f2c5 commit a55fdc4

3 files changed

Lines changed: 337 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
The Console served at `/_console/` now tells the browser how long it may keep each file, so a self-hosted console load stops downloading the whole SPA again on every visit.
6+
7+
Clause-②: no
8+
9+
- **Content-hashed build outputs** (a file directly under `assets/` named `name-hash.ext`, the hash being Vite's 8-character one) are sent with `cache-control: public, max-age=31536000, immutable`. A rebuild that changes the bytes emits a new name, so the browser never needs to ask again. Before this change, every non-HTML file was sent with a `content-type` and nothing else, so the browser could reuse none of the roughly 2,450 chunks.
10+
- **Every other non-HTML file** gets `cache-control: public, max-age=300` and an `etag` computed from the file's bytes, and a matching `If-None-Match` is answered with `304` and no body. This covers the two stable-named maplibre files objectui copies into `assets/` (`maplibre-gl-worker.mjs`, `maplibre-gl-shared.mjs`) and the root files such as `favicon.svg` and `manifest.json`. These names can carry different bytes in the next console release, so they are never marked `immutable`. The `etag` is computed from the bytes, not the file's modification time, because a console dist extracted with `tar` gives every file the same fixed mtime.
11+
- **HTML is unchanged**: the shell, a direct `index.html` and the SPA fallback are still rewritten per request and carry no lifetime.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* How long a browser may keep each file the console static plugin serves.
5+
*
6+
* Before this change, every non-HTML file under the console dist was answered
7+
* with a `content-type` and nothing else. With no lifetime and no validator, a
8+
* browser could reuse none of the ~2,450 chunks, so every self-hosted console
9+
* load downloaded the whole SPA again. Files now fall into three classes:
10+
*
11+
* - A content-hashed build output (`assets/<name>-<hash>.<ext>`) gets
12+
* `public, max-age=31536000, immutable`.
13+
* - Every other non-HTML file gets a short lifetime and a content `etag`,
14+
* and a matching `If-None-Match` is answered with `304`. This covers
15+
* objectui's stable-named maplibre copies in `assets/` and the `public/`
16+
* copies at the root.
17+
* - HTML (the shell, a direct `index.html`, the SPA fallback) gets no
18+
* lifetime, because it is rewritten per request.
19+
*
20+
* The hashed names below are real ones, read from the published
21+
* `@objectstack/console@17.7.0` dist. They were picked for the shapes a
22+
* name-only rule can get wrong: `-` and `_` inside the hash, a hash whose only
23+
* non-lowercase character is `_`, and a `[name]` that itself ends in an
24+
* uppercase dash segment. The two maplibre files are the only files in that
25+
* dist's `assets/` without a hash. The lookalikes are not from the dist; they
26+
* mark the rule's edges, where a stable file would otherwise be pinned for a
27+
* year.
28+
*
29+
* These tests run the REAL plugin on a REAL Hono app (the `HonoHttpServer`
30+
* that production resolves as `http.server`).
31+
*/
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
import fs from 'node:fs';
35+
import os from 'node:os';
36+
import path from 'node:path';
37+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
38+
39+
import { createConsoleStaticPlugin } from './console.js';
40+
41+
const ORIGIN = 'http://console.example.test';
42+
43+
const IMMUTABLE = 'public, max-age=31536000, immutable';
44+
45+
/** The longest lifetime a file that can change under its name may be given. */
46+
const SHORT_LIFETIME_CEILING_S = 300;
47+
48+
/** Real hashed names from the published dist, each a shape the rule must match. */
49+
const HASHED_ASSETS = [
50+
'index-v2Ijn9Gr.css',
51+
'index-ocmkyCt6.js',
52+
'maplibre-gl-Dw-skPj7.js',
53+
'vendor-icon-calendar-sync-Ccwp9IC-.js',
54+
'vendor-icon-arrow-down-Bo-fuW7k.js',
55+
'solarized-light-C1IUL_tW.js',
56+
'vendor-icon-diamond-percent-ekt_smur.js',
57+
'chunk-POPQ4Y6H-D8IrqNW7.js',
58+
];
59+
60+
/** The published dist's only `assets/` files without a hash, and two root `public/` copies. */
61+
const STABLE_FILES = ['assets/maplibre-gl-worker.mjs', 'assets/maplibre-gl-shared.mjs', 'favicon.svg', 'manifest.json'];
62+
63+
/** Not from the dist: names at the rule's edges, each of which must stay revalidated. */
64+
const LOOKALIKES = [
65+
'assets/pdf-renderer.js', // an eight-letter lowercase word after a dash
66+
'assets/maplibre-gl-worker-dev.mjs', // maplibre's other worker name
67+
'logo-Cluzi2Zq.svg', // hash-shaped, but outside assets/
68+
'assets/nested/chunk-AbCd1234.js', // hash-shaped, but below a subdirectory
69+
];
70+
71+
const HTML_REQUESTS = ['/_console/', '/_console/index.html', '/_console/apps/crm/records/42'];
72+
73+
let consoleRoot: string;
74+
75+
beforeAll(() => {
76+
consoleRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-cache-'));
77+
});
78+
79+
afterAll(() => {
80+
fs.rmSync(consoleRoot, { recursive: true, force: true });
81+
});
82+
83+
/** The bytes the fixture holds for a dist-relative path. */
84+
function fixtureBytes(rel: string): Buffer {
85+
return Buffer.from(`/* ${rel} */\n`);
86+
}
87+
88+
/** A console dist holding `index.html` and every named file. */
89+
function makeDist(name: string, files: string[]): string {
90+
const dist = path.join(consoleRoot, name);
91+
fs.mkdirSync(dist, { recursive: true });
92+
fs.writeFileSync(path.join(dist, 'index.html'), '<!doctype html><html><head></head><body>console</body></html>');
93+
for (const rel of files) {
94+
fs.mkdirSync(path.dirname(path.join(dist, rel)), { recursive: true });
95+
fs.writeFileSync(path.join(dist, rel), fixtureBytes(rel));
96+
}
97+
return dist;
98+
}
99+
100+
async function mountConsole(dist: string): Promise<(p: string, init?: RequestInit) => Promise<Response>> {
101+
const server = new HonoHttpServer(0);
102+
const plugin = createConsoleStaticPlugin(dist, { rootRedirect: false });
103+
await plugin.start({ getServiceAsync: async () => server, logger: { warn: () => {} } });
104+
const app = server.getRawApp();
105+
return async (p: string, init?: RequestInit) => app.request(`${ORIGIN}${p}`, init);
106+
}
107+
108+
/** `max-age` in seconds, or `null` when the header carries none. */
109+
function maxAgeOf(cacheControl: string | null): number | null {
110+
const match = /(?:^|,)\s*max-age=(\d+)/i.exec(cacheControl ?? '');
111+
return match ? Number(match[1]) : null;
112+
}
113+
114+
/** Whether a response lets the browser reuse it without asking: any positive lifetime, or `immutable`. */
115+
function grantsLifetime(cacheControl: string | null): boolean {
116+
return /\bimmutable\b/i.test(cacheControl ?? '') || (maxAgeOf(cacheControl) ?? 0) > 0;
117+
}
118+
119+
describe('a content-hashed build output is immutable for a year', () => {
120+
let request: Awaited<ReturnType<typeof mountConsole>>;
121+
beforeAll(async () => {
122+
request = await mountConsole(makeDist('hashed', HASHED_ASSETS.map((name) => `assets/${name}`)));
123+
});
124+
125+
it.each(HASHED_ASSETS)('assets/%s', async (name) => {
126+
const res = await request(`/_console/assets/${name}`);
127+
expect(res.status).toBe(200);
128+
expect(res.headers.get('cache-control')).toBe(IMMUTABLE);
129+
expect(Buffer.from(await res.arrayBuffer())).toEqual(fixtureBytes(`assets/${name}`));
130+
});
131+
132+
it('a missing hashed asset is a 404 the browser may not keep', async () => {
133+
const res = await request('/_console/assets/index-Zz9Zz9Zz.js');
134+
expect(res.status).toBe(404);
135+
expect(grantsLifetime(res.headers.get('cache-control'))).toBe(false);
136+
});
137+
});
138+
139+
describe('a file that can change under its name gets a short lifetime and a content etag, never immutable', () => {
140+
let request: Awaited<ReturnType<typeof mountConsole>>;
141+
beforeAll(async () => {
142+
request = await mountConsole(makeDist('stable', [...STABLE_FILES, ...LOOKALIKES]));
143+
});
144+
145+
it.each([...STABLE_FILES, ...LOOKALIKES])('%s', async (rel) => {
146+
const res = await request(`/_console/${rel}`);
147+
expect(res.status).toBe(200);
148+
const cacheControl = res.headers.get('cache-control');
149+
expect(cacheControl).not.toMatch(/immutable/i);
150+
const maxAge = maxAgeOf(cacheControl);
151+
expect(maxAge).not.toBeNull();
152+
expect(maxAge!).toBeLessThanOrEqual(SHORT_LIFETIME_CEILING_S);
153+
expect(res.headers.get('etag')).toMatch(/^"[^"]+"$/);
154+
expect(Buffer.from(await res.arrayBuffer())).toEqual(fixtureBytes(rel));
155+
});
156+
});
157+
158+
describe('a stable file revalidates against its etag', () => {
159+
const WORKER = 'assets/maplibre-gl-worker.mjs';
160+
let request: Awaited<ReturnType<typeof mountConsole>>;
161+
let etag: string;
162+
let cacheControl: string | null;
163+
164+
beforeAll(async () => {
165+
request = await mountConsole(makeDist('revalidate', [WORKER]));
166+
const first = await request(`/_console/${WORKER}`);
167+
etag = first.headers.get('etag')!;
168+
cacheControl = first.headers.get('cache-control');
169+
});
170+
171+
it.each([
172+
['its own tag', () => etag],
173+
['its own tag, weak', () => `W/${etag}`],
174+
['a list holding its tag', () => `"some-other-tag", ${etag}`],
175+
['*', () => '*'],
176+
])('answers 304 with no body to If-None-Match: %s', async (_label, header) => {
177+
const res = await request(`/_console/${WORKER}`, { headers: { 'if-none-match': header() } });
178+
expect(res.status).toBe(304);
179+
expect(await res.text()).toBe('');
180+
expect(res.headers.get('etag')).toBe(etag);
181+
expect(res.headers.get('cache-control')).toBe(cacheControl);
182+
});
183+
184+
it('answers 200 with the bytes to an If-None-Match that names another tag', async () => {
185+
const res = await request(`/_console/${WORKER}`, { headers: { 'if-none-match': '"some-other-tag"' } });
186+
expect(res.status).toBe(200);
187+
expect(Buffer.from(await res.arrayBuffer())).toEqual(fixtureBytes(WORKER));
188+
});
189+
190+
it('gives new bytes a new tag even when their size and mtime are unchanged', async () => {
191+
// `tar` extraction gives every file of a console tarball the same fixed
192+
// mtime, so an upgrade can replace a file with bytes of the same length and
193+
// the same stat time. Only a validator computed from the content tells
194+
// the two apart.
195+
const dist = makeDist('replaced', [WORKER]);
196+
const file = path.join(dist, WORKER);
197+
const packTime = new Date('1985-10-26T08:15:00Z');
198+
fs.utimesSync(file, packTime, packTime);
199+
const requestReplaced = await mountConsole(dist);
200+
const before = await requestReplaced(`/_console/${WORKER}`);
201+
const oldTag = before.headers.get('etag')!;
202+
203+
const next = Buffer.from(fixtureBytes(WORKER).toString().toUpperCase());
204+
expect(next.length).toBe(fixtureBytes(WORKER).length);
205+
fs.writeFileSync(file, next);
206+
fs.utimesSync(file, packTime, packTime);
207+
208+
const after = await requestReplaced(`/_console/${WORKER}`, { headers: { 'if-none-match': oldTag } });
209+
expect(after.status).toBe(200);
210+
expect(after.headers.get('etag')).not.toBe(oldTag);
211+
expect(Buffer.from(await after.arrayBuffer())).toEqual(next);
212+
});
213+
});
214+
215+
describe('HTML is rewritten per request and is never given a lifetime', () => {
216+
let request: Awaited<ReturnType<typeof mountConsole>>;
217+
beforeAll(async () => {
218+
request = await mountConsole(makeDist('html', []));
219+
});
220+
221+
it.each(HTML_REQUESTS)('%s', async (p) => {
222+
const res = await request(p);
223+
expect(res.status).toBe(200);
224+
expect(res.headers.get('content-type')).toMatch(/^text\/html/);
225+
expect(grantsLifetime(res.headers.get('cache-control'))).toBe(false);
226+
});
227+
});

‎packages/cli/src/utils/console.ts‎

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
*/
4646
import path from 'path';
4747
import fs from 'fs';
48+
import { createHash } from 'crypto';
4849
import { createRequire } from 'module';
4950
import { pathToFileURL, fileURLToPath } from 'url';
5051

@@ -544,6 +545,88 @@ async function resolveHttpServer(ctx: any): Promise<any> {
544545
}
545546
}
546547

548+
// ─── Console static-file caching ────────────────────────────────────
549+
550+
/**
551+
* `cache-control` for a console file whose NAME carries its content hash
552+
* ({@link isContentHashedConsoleAsset}). Its bytes can never change under that
553+
* name, so the browser keeps it for a year and never revalidates it. A rebuild
554+
* that changes the bytes emits a new name, and the HTML shell, which is always
555+
* revalidated, points at the new name.
556+
*/
557+
const HASHED_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
558+
559+
/**
560+
* `cache-control` for every other non-HTML console file. These names are copied
561+
* into the build unchanged, so the next console release can ship different
562+
* bytes under the same name. objectui copies maplibre's `maplibre-gl-worker.mjs`
563+
* and `maplibre-gl-shared.mjs` into `assets/` this way, and `favicon.svg` and
564+
* the root JSON files are `public/` copies. The short lifetime limits how long
565+
* an upgraded deployment can leave a browser on the old bytes. The `etag` sent
566+
* with it ({@link contentEtag}) turns each revalidation into a `304` instead of
567+
* a full download.
568+
*/
569+
const STABLE_FILE_CACHE_CONTROL = 'public, max-age=300';
570+
571+
/**
572+
* Vite's name for an emitted file: `assets/[name]-[hash][extname]`, its default
573+
* `chunkFileNames` and `assetFileNames`. The hash is 8 characters of rollup's
574+
* base64url alphabet, so `-` and `_` can appear inside it
575+
* (`vendor-icon-calendar-sync-Ccwp9IC-.js`).
576+
*/
577+
const CONTENT_HASHED_ASSET_NAME = /^[^/]+-([A-Za-z0-9_-]{8})\.[A-Za-z0-9]+$/;
578+
579+
/**
580+
* Whether a console dist file is a content-hashed build output. It is judged
581+
* from the dist-relative path alone: the file sits directly under `assets/`, is
582+
* named `<name>-<hash>.<ext>`, and the hash contains at least one uppercase
583+
* letter, digit or `_`.
584+
*
585+
* The rule comes from a real build, not from memory. Measured over the
586+
* published dist of `@objectstack/console@17.7.0` (2457 files under `assets/`),
587+
* it matches all 2455 hashed chunks and neither stable-named file
588+
* (`maplibre-gl-worker.mjs`, `maplibre-gl-shared.mjs`).
589+
*
590+
* The character condition narrows the match. Going by shape alone, an
591+
* eight-letter lowercase word after a dash (`-renderer.js`) looks like a hash,
592+
* and a stable file mistaken for a hashed one stays pinned in browsers for a
593+
* year. Every measured hash contains such a character. A real hash that
594+
* happens not to is only revalidated like a stable file; it is never served
595+
* stale.
596+
*/
597+
function isContentHashedConsoleAsset(distRelativePath: string): boolean {
598+
const segments = distRelativePath.split('/').filter(Boolean);
599+
if (segments.length !== 2 || segments[0] !== 'assets') return false;
600+
const match = CONTENT_HASHED_ASSET_NAME.exec(segments[1]);
601+
return match !== null && /[A-Z0-9_]/.test(match[1]);
602+
}
603+
604+
/**
605+
* A strong `etag` computed from the file's BYTES. ⛔ It is not computed from
606+
* the file's stat, because the stat records how the dist was laid down, not
607+
* what it contains. Measured on `@objectstack/console@17.7.0`: `npm install`
608+
* stamps every file with the install time, but plain `tar` extraction of the
609+
* same tarball keeps npm's fixed pack mtime (1985-10-26T08:15:00Z, on every
610+
* file). Under `tar`, two releases' different `maplibre-gl-worker.mjs` would
611+
* carry the same `last-modified`, and a validator built from the stat would
612+
* answer `304` for bytes the browser has never seen.
613+
*/
614+
function contentEtag(content: Buffer): string {
615+
return `"${createHash('sha256').update(content).digest('base64url')}"`;
616+
}
617+
618+
/**
619+
* RFC 9110 §13.1.2: `If-None-Match` hits on `*` or on any listed tag, using
620+
* weak comparison.
621+
*/
622+
function ifNoneMatchHits(header: string | undefined, etag: string): boolean {
623+
if (!header) return false;
624+
return header.split(',').some((candidate) => {
625+
const tag = candidate.trim();
626+
return tag === '*' || tag.replace(/^W\//, '') === etag;
627+
});
628+
}
629+
547630
/**
548631
* Create a lightweight kernel plugin that serves the pre-built Console
549632
* portal static files at `/_console/*`.
@@ -556,6 +639,11 @@ async function resolveHttpServer(ctx: any): Promise<any> {
556639
* a real 404 surfaces a rebuild/deploy mismatch instead of the
557640
* dreaded "asset returns text/html" silent failure.
558641
*
642+
* Caching, per file: a content-hashed build output is `immutable` for a
643+
* year; every other non-HTML file gets a short lifetime and a content `etag`
644+
* answered with `304`; HTML (the shell and the SPA fallback) carries neither,
645+
* so the browser fetches it again every time.
646+
*
559647
* It also answers the path an author writes as a public form's
560648
* `sharing.publicLink`: `GET /forms/<slug>` redirects (302) to the Console's
561649
* public form page, `/_console/f/<slug>` with the request's query string, when
@@ -672,8 +760,18 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
672760
});
673761
}
674762
const content = fs.readFileSync(filePath);
763+
if (isContentHashedConsoleAsset(reqPath)) {
764+
return new Response(content, {
765+
headers: { 'content-type': mimeType(filePath), 'cache-control': HASHED_ASSET_CACHE_CONTROL },
766+
});
767+
}
768+
const etag = contentEtag(content);
769+
const validated = { 'cache-control': STABLE_FILE_CACHE_CONTROL, etag };
770+
if (ifNoneMatchHits(c.req.header('if-none-match'), etag)) {
771+
return new Response(null, { status: 304, headers: validated });
772+
}
675773
return new Response(content, {
676-
headers: { 'content-type': mimeType(filePath) },
774+
headers: { 'content-type': mimeType(filePath), ...validated },
677775
});
678776
}
679777

0 commit comments

Comments
 (0)