From fefff3ae9ec16ced7c5d4e7a801f42b9c0389af1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 14 Apr 2026 05:40:42 +0200 Subject: [PATCH] Cache-busting version stamps + client-side encounter version tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Build-ID cache busting (server.js): - Compute a BUILD_ID at boot: git HEAD short hash if available, else /app/BUILD_ID file, else random-on-boot. - On first request for /, rewrite every local /js/*.js and /css/*.css reference in index.html to include ?v=BUILD_ID. Cached once at startup so subsequent renders are free. - X-Build-Id response header + GET /api/build expose it for debugging. - Eliminates the "works after hard-refresh" class of bugs: every deploy gets a new build ID, so browsers fetch fresh JS/CSS on the very next page load. 2. Optimistic encounter locking wired into the client (public/js/encounters.js): - On resumeEncounter(): stash enc.version into window._encounterVersions[id] - On saveEncounter(): send expected_version in the POST body when we have one. - Server returns 409 if another tab/device wrote first → user sees "Someone else edited this encounter. Reload to see the latest version." instead of silently clobbering the prior save. - On success, remember the new server-assigned version for the next save. --- public/js/encounters.js | 24 ++++++++++++++++++-- server.js | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/public/js/encounters.js b/public/js/encounters.js index 87bc90d..657a85a 100644 --- a/public/js/encounters.js +++ b/public/js/encounters.js @@ -117,16 +117,31 @@ // Prevent duplicate saves from double-click if (_savingInProgress[type]) { showToast('Save in progress…', 'info'); return; } _savingInProgress[type] = true; + // Optimistic locking: send the version we last saw. Server returns 409 + // if the encounter was updated elsewhere (another tab, another device). + var body = Object.assign({}, opts); + if (opts.id && window._encounterVersions && window._encounterVersions[opts.id] != null) { + body.expected_version = window._encounterVersions[opts.id]; + } fetch('/api/encounters/saved', { method: 'POST', headers: getAuthHeaders(), - body: JSON.stringify(opts) + body: JSON.stringify(body) }) - .then(function(r) { return r.json(); }) + .then(function(r) { return r.json().then(function(d) { d._status = r.status; return d; }); }) .then(function(data) { _savingInProgress[type] = false; + if (data._status === 409) { + showToast('Someone else edited this encounter. Reload to see the latest version.', 'error'); + return; + } if (data.success) { showToast('Encounter saved (' + (opts.label || '') + ')', 'success'); + // Track the new version the server assigned + if (data.id != null && data.version != null) { + window._encounterVersions = window._encounterVersions || {}; + window._encounterVersions[data.id] = data.version; + } // Persist ID so refreshing the page won't create a duplicate if (data.id) { if (opts.onSaved) opts.onSaved(data.id); @@ -275,6 +290,11 @@ .then(function(data) { if (!data.success) { showToast(data.error || 'Failed', 'error'); return; } var enc = data.encounter; + // Remember the loaded version so future saves can detect drift + if (enc && enc.id != null && enc.version != null) { + window._encounterVersions = window._encounterVersions || {}; + window._encounterVersions[enc.id] = enc.version; + } // Close all popovers ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) { var pop = document.getElementById(p + '-load-popover'); diff --git a/server.js b/server.js index c8f55bc..01dce8c 100644 --- a/server.js +++ b/server.js @@ -130,6 +130,55 @@ app.get('/.well-known/assetlinks.json', (req, res) => { res.sendFile(path.join(__dirname, 'public', '.well-known', 'assetlinks.json')); }); +// ============================================================ +// CACHE-BUSTING VERSION STAMP +// ============================================================ +// Compute a per-boot BUILD_ID (short hex). Inject it as ?v=BUILD_ID +// on every local /js/*.js and /css/*.css reference in index.html so +// browsers always fetch fresh JS/CSS after a deploy instead of +// serving from the 1-hour cache. +var fs = require('fs'); +var crypto = require('crypto'); +var BUILD_ID = crypto.randomBytes(4).toString('hex'); +try { + var gitHead = fs.readFileSync(path.join(__dirname, '.git/HEAD'), 'utf8').trim(); + if (gitHead.indexOf('ref:') === 0) { + var refPath = gitHead.split(' ')[1]; + BUILD_ID = fs.readFileSync(path.join(__dirname, '.git', refPath), 'utf8').trim().slice(0, 7); + } else { + BUILD_ID = gitHead.slice(0, 7); + } +} catch (e) { + // Non-git environment (Docker image) — use /app/BUILD_ID file if present, + // otherwise stick with the random-on-boot value generated above. + try { + BUILD_ID = fs.readFileSync(path.join(__dirname, 'BUILD_ID'), 'utf8').trim() || BUILD_ID; + } catch (_) {} +} +console.log('🔖 Build ID:', BUILD_ID); + +// Template index.html once at boot with the version stamp. Serves fast +// because the rewrite happens one time and the string is reused. +var INDEX_HTML_TEMPLATED = null; +try { + var raw = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'); + INDEX_HTML_TEMPLATED = raw.replace( + /(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g, + '$1$2?v=' + BUILD_ID + '$3' + ); +} catch (e) { console.warn('[build-id] index.html template skipped:', e.message); } + +app.get(['/', '/index.html'], function(req, res) { + if (!INDEX_HTML_TEMPLATED) return res.sendFile(path.join(__dirname, 'public', 'index.html')); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('X-Build-Id', BUILD_ID); + res.send(INDEX_HTML_TEMPLATED); +}); + +// Public endpoint for cache-bust debugging + build-info +app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); }); + app.use(loggingMiddleware); app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, filePath) => {