Skip to content

Commit 92e12f7

Browse files
committed
fix(web): stamp the service worker with a per-build id so PWA updates surface
The SW is bundled by a custom esbuild step with no build manifest, so public/sw.js was byte-identical across builds unless sw.ts itself changed. The browser fetches /sw.js (no-store), byte-compares to the installed worker, finds it identical, and never installs a new one — so the 'waiting' event never fires, SwUpdateNotice never shows, and users stay frozen on their first-installed SW (stale app shell + style). build:sw now runs scripts/build-sw.mjs, which injects a per-build id (SW_BUILD_ID env / git SHA / timestamp) via esbuild define; sw.ts weaves it into the app-shell cache name so the bytes change every deploy. CI passes the commit SHA as a build arg (Dockerfile ARG before the build busts the layer cache; turbo build task tracks SW_BUILD_ID). Verified: distinct ids produce distinct sw.js.
1 parent 5aa56ed commit 92e12f7

6 files changed

Lines changed: 64 additions & 3 deletions

File tree

.github/workflows/docker.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ jobs:
129129
context: ${{ steps.ctx.outputs.context }}
130130
file: ${{ steps.ctx.outputs.dockerfile }}
131131
platforms: linux/amd64
132+
# Stamp the web service worker with the commit SHA so each deploy
133+
# produces a fresh sw.js and the PWA's update prompt fires. Ignored by
134+
# the other images (their Dockerfiles declare no such ARG).
135+
build-args: ${{ matrix.app == 'web' && format('SW_BUILD_ID={0}', github.sha) || '' }}
132136
push: true
133137
tags: ${{ steps.meta.outputs.tags }}
134138
labels: ${{ steps.meta.outputs.labels }}

apps/web/Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ ENV NEXT_PUBLIC_API_URL=__NEXT_PUBLIC_API_URL__
5959
ENV NEXT_TELEMETRY_DISABLED=1
6060

6161
WORKDIR /app/apps/web
62+
# Per-build service-worker id (CI passes the git SHA). Declared right before the
63+
# build so a new value busts this layer's cache and `build:sw` re-stamps sw.js,
64+
# letting the PWA detect the update. Defaults to empty for plain `docker build`,
65+
# in which case build-sw.mjs falls back to the git SHA / a timestamp.
66+
ARG SW_BUILD_ID=
67+
ENV SW_BUILD_ID=$SW_BUILD_ID
6268
RUN pnpm build
6369

6470
# Production image

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"build": "next build && pnpm build:sw && pnpm build:copy-assets",
99
"build:runtime": "node scripts/build-runtime-modules.mjs",
1010
"build:licenses": "node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/generate-license-notices.mjs",
11-
"build:sw": "esbuild src/sw.ts --bundle --outfile=public/sw.js --format=iife --platform=browser --define:process.env.NODE_ENV='\"production\"'",
11+
"build:sw": "node scripts/build-sw.mjs",
1212
"build:copy-assets": "mkdir -p .next/standalone/apps/web/public .next/standalone/apps/web/.next/static && cp -R public/. .next/standalone/apps/web/public/ && cp -R .next/static/. .next/standalone/apps/web/.next/static/",
1313
"start": "node .next/standalone/apps/web/server.js",
1414
"check-types": "pnpm build:licenses && tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",

apps/web/scripts/build-sw.mjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Bundles the service worker (src/sw.ts -> public/sw.js) and stamps it with a
2+
// per-build id via esbuild `define`. Without a build-varying value the output is
3+
// byte-identical across builds, so browsers never see a "new" service worker and
4+
// the in-app "update available" prompt (SwUpdateNotice) never fires. The id is
5+
// woven into the app-shell cache name in sw.ts, so it must change every deploy.
6+
//
7+
// Source of the id, in priority order:
8+
// 1. SW_BUILD_ID env var (CI passes the git SHA as a Docker build arg)
9+
// 2. the local git short SHA (dev builds)
10+
// 3. a timestamp fallback (no env, no git)
11+
import { execFileSync } from "node:child_process";
12+
import { build } from "esbuild";
13+
14+
export function resolveSwBuildId(env = process.env) {
15+
const fromEnv = (env.SW_BUILD_ID ?? "").trim();
16+
if (fromEnv) return fromEnv;
17+
try {
18+
// Fixed argv, no shell — nothing interpolated.
19+
return execFileSync("git", ["rev-parse", "--short", "HEAD"], {
20+
stdio: ["ignore", "pipe", "ignore"],
21+
})
22+
.toString()
23+
.trim();
24+
} catch {
25+
return `dev-${Date.now()}`;
26+
}
27+
}
28+
29+
if (process.argv[1]?.endsWith("build-sw.mjs")) {
30+
const buildId = resolveSwBuildId();
31+
await build({
32+
entryPoints: ["src/sw.ts"],
33+
bundle: true,
34+
outfile: "public/sw.js",
35+
format: "iife",
36+
platform: "browser",
37+
define: {
38+
"process.env.NODE_ENV": '"production"',
39+
__SW_BUILD_ID__: JSON.stringify(buildId),
40+
},
41+
});
42+
console.log(`[build:sw] public/sw.js built (build id: ${buildId})`);
43+
}

apps/web/src/sw.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,16 @@ declare global {
5050
}
5151
}
5252

53+
// Replaced at build time by scripts/build-sw.mjs (esbuild `define`) with a
54+
// per-build id. Weaving it into the app-shell cache name below makes sw.js's
55+
// bytes change every deploy, so the browser detects a new worker and
56+
// SwUpdateNotice can surface the "update available" prompt — without it the
57+
// custom esbuild output is identical across builds and updates never fire.
58+
declare const __SW_BUILD_ID__: string;
59+
5360
const OFFLINE_URL = "/offline";
5461
const HOME_URL = "/";
55-
const APP_SHELL_CACHE = "app-shell-v1";
62+
const APP_SHELL_CACHE = `app-shell-${__SW_BUILD_ID__}`;
5663
// `/` is precached so a user with downloaded offline areas can still reach
5764
// the map after the runtime `pages` cache has expired (24h / 20 entries).
5865
// Without this, the nav handler would fall through to /offline and the

turbo.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"build": {
66
"dependsOn": ["^build"],
77
"inputs": ["$TURBO_DEFAULT$", ".env*"],
8-
"outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"]
8+
"outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"],
9+
"env": ["SW_BUILD_ID"]
910
},
1011
"lint": {
1112
"dependsOn": ["^lint"]

0 commit comments

Comments
 (0)