[object Object]

← back to Interiordesignershowroom

perf: content-hash asset cache-buster (?v=) + immutable css/js caching — deploy-safe, logged fallback, dev-guard (Cody-hardened)

93f0ffc23aa44bcb40e754e293660cc75bd3598e · 2026-08-03 11:59:13 -0700 · Steve Abrams

Files touched

Diff

commit 93f0ffc23aa44bcb40e754e293660cc75bd3598e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 11:59:13 2026 -0700

    perf: content-hash asset cache-buster (?v=) + immutable css/js caching — deploy-safe, logged fallback, dev-guard (Cody-hardened)
---
 lib/assetv.js | 26 +++++++++++++++++---------
 server.js     |  8 ++++++--
 2 files changed, 23 insertions(+), 11 deletions(-)

diff --git a/lib/assetv.js b/lib/assetv.js
index daa7abd..c897e56 100644
--- a/lib/assetv.js
+++ b/lib/assetv.js
@@ -1,23 +1,31 @@
-// Asset cache-buster. Computes a short version hash from the css/js file mtimes at
-// boot; v('/css/site.css') -> '/css/site.css?v=<hash>'. When a css/js file changes and
-// the server restarts (i.e. a deploy), the hash changes -> the URL changes -> browsers
-// fetch fresh. This is what lets those assets be cached HARD (immutable 1y) without the
-// post-deploy stale-CSS risk that made cycle 8 keep them at max-age=0.
+// Asset cache-buster. Computes a short version hash from the CONTENT of the css/js
+// files at boot; v('/css/site.css') -> '/css/site.css?v=<hash>'. When a css/js file's
+// BYTES change and the server restarts (a deploy re-execs this module), the hash
+// changes -> the URL changes -> browsers fetch fresh. This is what lets those assets be
+// cached HARD (immutable 1y) without the post-deploy stale-CSS risk from cycle 8.
+//
+// CONTENT hash, not mtime: rsync/git-checkout/clone can set arbitrary mtimes on files
+// (a changed file can land with an OLDER mtime, or an unchanged file a NEWER one), so an
+// mtime hash can silently fail to bust (serve stale immutably for a year) or bust
+// needlessly. A content hash changes iff the bytes change — deploy method irrelevant.
 const fs = require('fs');
 const path = require('path');
 const crypto = require('crypto');
 
 function compute() {
   try {
-    let sig = '';
+    const hash = crypto.createHash('sha1');
     for (const d of ['css', 'js']) {
       const dir = path.join(__dirname, '..', 'public', d);
       for (const fn of fs.readdirSync(dir).sort()) {
-        sig += fn + fs.statSync(path.join(dir, fn)).mtimeMs;
+        hash.update(fn).update(fs.readFileSync(path.join(dir, fn)));
       }
     }
-    return crypto.createHash('sha1').update(sig).digest('hex').slice(0, 10);
-  } catch (_) {
+    return hash.digest('hex').slice(0, 10);
+  } catch (e) {
+    // Loud, not silent: a swallowed failure here would pin every asset to ?v=dev while
+    // immutable-1y stays active — i.e. serve stale for a year with no trace.
+    console.error('[assetv] content-hash failed, falling back to ?v=dev:', e.message);
     return 'dev';
   }
 }
diff --git a/server.js b/server.js
index 5451b01..fd9aee3 100644
--- a/server.js
+++ b/server.js
@@ -37,8 +37,12 @@ app.use(express.json({ limit: '256kb' }));
 // bare (no ?v=), so any max-age>0 risks serving stale css/js against fresh HTML after a
 // deploy. Default (max-age=0 + ETag) makes them cheap 304 revalidations instead. A ?v=
 // cache-buster is the future upgrade that would let these be cached hard.
-app.use('/css', express.static(path.join(__dirname, 'public/css'), { maxAge: '365d', immutable: true }));
-app.use('/js', express.static(path.join(__dirname, 'public/js'), { maxAge: '365d', immutable: true }));
+// Hard-cache css/js (their ?v= busts on content change). Default ON so it works even
+// when NODE_ENV is unset; opt into dev ergonomics (edit + reload, no restart) with
+// NODE_ENV=development, which drops to revalidate-every-load.
+const STATIC_CACHE = process.env.NODE_ENV === 'development' ? { maxAge: 0 } : { maxAge: '365d', immutable: true };
+app.use('/css', express.static(path.join(__dirname, 'public/css'), STATIC_CACHE));
+app.use('/js', express.static(path.join(__dirname, 'public/js'), STATIC_CACHE));
 // Images split by staleness profile: /img/rooms scene images are 16-hex CONTENT-HASHED
 // (new content = new URL) so they're safely immutable for a year; everything else (guide
 // editorial images, favicons, og.png — stable, human-readable names) gets a short 1d TTL

← 3b62dda auto-save: 2026-08-03T11:53:46 (3 files) — lib/render.js ser  ·  back to Interiordesignershowroom  ·  security: full script-src 'self' CSP on public (delegated al 27b1775 →