Repository navigation
Commit a55fdc4
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
- .changeset
- packages/cli/src/utils
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| 48 | + | |
48 | 49 | | |
49 | 50 | | |
50 | 51 | | |
| |||
544 | 545 | | |
545 | 546 | | |
546 | 547 | | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
547 | 630 | | |
548 | 631 | | |
549 | 632 | | |
| |||
556 | 639 | | |
557 | 640 | | |
558 | 641 | | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
559 | 647 | | |
560 | 648 | | |
561 | 649 | | |
| |||
672 | 760 | | |
673 | 761 | | |
674 | 762 | | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
675 | 773 | | |
676 | | - | |
| 774 | + | |
677 | 775 | | |
678 | 776 | | |
679 | 777 | | |
| |||
0 commit comments