Skip to content

Commit 51ad97e

Browse files
pixlheroclaude
andcommitted
Restore Cache-Control, ship both filenames, publish identifiable builds
Three related gaps in how dist/ is published, all handled by a postbuild step so the layout is produced in one place. Cache-Control: the gsutil deploys used to set it explicitly (no-store on dev, max-age on prod). The move to rsync in 950b9e0 dropped that, and nothing noticed because .htaccess only ever existed on the server. It is now in the repo per environment and shipped with the bundle, into the ar-button/ directory. Apache merges it with the docroot .htaccess that sets CORS; the two touch different headers so they do not collide. Without it responses carry no Cache-Control at all and browsers fall back to heuristic caching, holding a stale bundle for weeks. Filenames: build:prod used to `mv` the bundle to ar-button.min.js, so each environment served one name and only one. That name has shipped in customer embeds since the Vue CLI days and can never disappear, so it is now a copy rather than a rename. Both environments publish both names, which lets both move to ar-button.js without breaking anything already embedded. Identifiability: the stable names cannot be hashed, since customers control those references. Instead each build also publishes an immutable content-addressed copy (cacheable for a year, and available to pin against) plus a version.json naming the version, hash and commit that is live. Answering "which build is deployed?" previously meant comparing CDN Last-Modified headers by hand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c7b13ee commit 51ad97e

4 files changed

Lines changed: 117 additions & 2 deletions

File tree

‎deploy/htaccess.dev‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Deployed into the ar-button/ directory by `yarn deploy:dev`.
2+
#
3+
# Apache merges this with the docroot .htaccess, which sets the CORS headers.
4+
# Only Cache-Control is set here, so the two never collide.
5+
#
6+
# The gsutil deploys used to set "no-store, no-cache" on dev. That was lost in
7+
# the move to rsync (Dec 2025), leaving responses with no Cache-Control at all,
8+
# so browsers fell back to heuristic caching and held the bundle for weeks.
9+
10+
<IfModule mod_headers.c>
11+
# Dev must never be cached: a redeploy has to be visible on reload.
12+
<FilesMatch "^(ar-button(\.min)?\.js|version\.json)$">
13+
Header set Cache-Control "no-store, no-cache, must-revalidate"
14+
</FilesMatch>
15+
16+
# Content-addressed builds never change, so they can be cached forever.
17+
<FilesMatch "^ar-button-.+\.js$">
18+
Header set Cache-Control "public, max-age=31536000, immutable"
19+
</FilesMatch>
20+
</IfModule>

‎deploy/htaccess.prod‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Deployed into the ar-button/ directory by `yarn deploy:prod`.
2+
#
3+
# Apache merges this with the docroot .htaccess, which sets the CORS headers.
4+
# Only Cache-Control is set here, so the two never collide.
5+
6+
<IfModule mod_headers.c>
7+
# Customers embed these filenames directly, so they can never be renamed or
8+
# hashed. They must stay revalidating instead, or a redeploy takes weeks to
9+
# reach browsers. Revalidation is cheap: ETag turns it into a 304.
10+
<FilesMatch "^(ar-button(\.min)?\.js|version\.json)$">
11+
Header set Cache-Control "public, max-age=300, must-revalidate"
12+
</FilesMatch>
13+
14+
# Content-addressed builds never change, so they can be cached forever.
15+
# Pin one of these if you need a build that cannot shift under you.
16+
<FilesMatch "^ar-button-.+\.js$">
17+
Header set Cache-Control "public, max-age=31536000, immutable"
18+
</FilesMatch>
19+
</IfModule>

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
"scripts": {
1616
"serve": "parcel ./demo.html --port 8080 --target default",
1717
"watch": "parcel watch --target default",
18-
"build:dev": "rm -rf dist/* && NODE_ENV=development parcel build --target default",
18+
"build:dev": "rm -rf dist && NODE_ENV=development parcel build --target default && node scripts/postbuild.mjs dev",
1919
"test:smoke": "node test/smoke.mjs",
20-
"build:prod": "rm -rf dist/* && NODE_ENV=production parcel build --target default && mv dist/ar-button.js dist/ar-button.min.js",
20+
"build:prod": "rm -rf dist && NODE_ENV=production parcel build --target default && node scripts/postbuild.mjs prod",
2121
"deploy:dev": "yarn build:dev && rsync -avz dist/ loxanimo@bitforge.ch:www/dist.dev.yago.cloud/ar-button/",
2222
"deploy:prod": "yarn build:prod && rsync -avz --exclude='*.map' dist/ loxanimo@bitforge.ch:www/dist.yago.cloud/ar-button/"
2323
},

‎scripts/postbuild.mjs‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Post-build step: lay out dist/ so a deploy is both stable and identifiable.
3+
*
4+
* Produces, from the single bundle Parcel emits:
5+
*
6+
* ar-button.js stable entry, what new embeds should use
7+
* ar-button.min.js identical copy under the legacy filename
8+
* ar-button-<version>-<hash>.js immutable, content-addressed build
9+
* version.json what is actually deployed here
10+
* .htaccess per-environment Cache-Control
11+
*
12+
* Why a copy and not the old `mv`: production URLs have shipped as
13+
* ar-button.min.js since the Vue CLI days and are embedded on customer sites,
14+
* so that name can never disappear. Emitting both lets both environments move
15+
* to ar-button.js without breaking anything already out there.
16+
*
17+
* The hashed copy exists because the stable names cannot be hashed - customers
18+
* control those references. It gives something immutable to pin, and makes
19+
* "which build is live?" answerable with one request instead of an
20+
* investigation into CDN headers.
21+
*/
22+
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
23+
import { createHash } from 'node:crypto';
24+
import { execSync } from 'node:child_process';
25+
import { join } from 'node:path';
26+
27+
const env = process.argv[2];
28+
if (env !== 'dev' && env !== 'prod') {
29+
console.error('usage: node scripts/postbuild.mjs <dev|prod>');
30+
process.exit(1);
31+
}
32+
33+
const DIST = 'dist';
34+
const ENTRY = join(DIST, 'ar-button.js');
35+
36+
if (!existsSync(ENTRY)) {
37+
console.error(`✗ ${ENTRY} not found - did the parcel build run?`);
38+
process.exit(1);
39+
}
40+
41+
const bundle = readFileSync(ENTRY);
42+
const hash = createHash('sha256').update(bundle).digest('hex').slice(0, 8);
43+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
44+
const immutable = `ar-button-${pkg.version}-${hash}.js`;
45+
46+
// Legacy filename, kept forever for embeds already in the wild.
47+
copyFileSync(ENTRY, join(DIST, 'ar-button.min.js'));
48+
copyFileSync(ENTRY, join(DIST, immutable));
49+
50+
const htaccess = join('deploy', `htaccess.${env}`);
51+
if (!existsSync(htaccess)) {
52+
console.error(`✗ ${htaccess} not found`);
53+
process.exit(1);
54+
}
55+
copyFileSync(htaccess, join(DIST, '.htaccess'));
56+
57+
let commit = 'unknown';
58+
try {
59+
commit = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
60+
.toString()
61+
.trim();
62+
} catch {
63+
// building outside a git checkout is fine, just less traceable
64+
}
65+
66+
const info = {
67+
version: pkg.version,
68+
hash,
69+
commit,
70+
env,
71+
built: new Date().toISOString(),
72+
files: { latest: 'ar-button.js', legacy: 'ar-button.min.js', immutable },
73+
};
74+
writeFileSync(join(DIST, 'version.json'), JSON.stringify(info, null, 2) + '\n');
75+
76+
console.log(`postbuild (${env}): ${immutable} commit ${commit}`);

0 commit comments

Comments
 (0)