diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 2e2c972..aba9e20 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -4,11 +4,14 @@ set -euo pipefail HOST="${1:?usage: deploy.sh }" npm run build --workspace=@wizwar/web -rsync -az --delete \ +# Old hashed chunks stay a week: a page loaded before this deploy can still +# fetch the module it was built against instead of failing mid-game. +rsync -az --delete --filter='P packages/web/dist/assets/*' \ --exclude node_modules --exclude data/ --exclude .git --exclude research \ ./ "root@$HOST:/opt/wizwar/" ssh "root@$HOST" ' cd /opt/wizwar && npm install --no-audit --no-fund + find /opt/wizwar/packages/web/dist/assets -type f -mtime +7 -delete chown -R wizwar:wizwar /opt/wizwar cp /opt/wizwar/deploy/wizwar.service /etc/systemd/system/wizwar.service # Cron runs the backup and the rollup from /usr/local/bin: install diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6b29495..8444dab 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -387,7 +387,14 @@ const httpServer = createServer((req, res) => { res.writeHead(403).end(); return; } - if (!existsSync(file)) file = join(staticRoot, "index.html"); // SPA fallback + if (!existsSync(file)) { + // A hashed asset that is gone is gone: a page built before the last + // deploy asking for its old chunk gets a plain 404 it can act on, + // never the app shell as text/html. Every other unknown path is the + // app's to route. + if (url.startsWith("/assets/")) { res.writeHead(404, { "content-type": "text/plain" }).end("gone"); return; } + file = join(staticRoot, "index.html"); + } // Resolve symlinks and re-verify the real location stays inside the root. const real = realpathSync(file); if (real !== staticRoot && !real.startsWith(staticRoot + sep)) { diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts index 1d4a54f..b78f317 100644 --- a/packages/web/src/main.ts +++ b/packages/web/src/main.ts @@ -7,6 +7,19 @@ if (import.meta.env.VITE_SENTRY_DSN) { Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: "production" }); } +// A page loaded before a deploy may ask for a chunk the deploy retired. +// Vite reports the failed import here; one reload fetches the live bundle. +// The flag keeps a broken build from reloading forever. +window.addEventListener("vite:preloadError", (e) => { + e.preventDefault(); + try { + if (sessionStorage.getItem("wizwar-reloaded")) return; + sessionStorage.setItem("wizwar-reloaded", "1"); + } catch { /* storage may be unavailable; reload anyway */ } + location.reload(); +}); +try { sessionStorage.removeItem("wizwar-reloaded"); } catch { /* ignore */ } + // /watch/ is the public share page — one turn, anyone may look. It // loads its own component so the full app (and its live socket appetite) // stays out of a shared link's way.