From 91be465cbc006ac7432bd3699dc7a59ef4bdd950 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 11:13:31 +0100
Subject: [PATCH] feat: version the service worker by build and offer a reload
after a deploy
---
app/(main)/[locale]/layout.tsx | 2 +
app/api/version/route.ts | 16 +++
.../__tests__/app-update-prompt.test.tsx | 83 ++++++++++++++
components/app-update-prompt.tsx | 69 ++++++++++++
components/service-worker-registration.tsx | 12 +-
lib/__tests__/app-update.test.ts | 76 +++++++++++++
lib/__tests__/sw-build-caches.test.ts | 48 ++++++++
lib/__tests__/sw-harness.ts | 50 +++++++++
lib/app-update.ts | 103 ++++++++++++++++++
lib/web-push.ts | 6 +-
locales/ar/common.json | 4 +
locales/ca/common.json | 4 +
locales/cs/common.json | 4 +
locales/da/common.json | 4 +
locales/de/common.json | 4 +
locales/en/common.json | 4 +
locales/es/common.json | 4 +
locales/et/common.json | 4 +
locales/fa/common.json | 4 +
locales/fr/common.json | 4 +
locales/he/common.json | 4 +
locales/hu/common.json | 4 +
locales/it/common.json | 4 +
locales/ja/common.json | 4 +
locales/ko/common.json | 4 +
locales/lv/common.json | 4 +
locales/mn/common.json | 4 +
locales/nb/common.json | 4 +
locales/nl/common.json | 4 +
locales/pl/common.json | 4 +
locales/pt/common.json | 4 +
locales/ro/common.json | 4 +
locales/ru/common.json | 4 +
locales/sk/common.json | 4 +
locales/th/common.json | 4 +
locales/tr/common.json | 4 +
locales/uk/common.json | 4 +
locales/vi/common.json | 4 +
locales/zh-TW/common.json | 4 +
locales/zh/common.json | 4 +
next.config.ts | 17 +++
public/sw.js | 87 ++++++++++++++-
42 files changed, 679 insertions(+), 10 deletions(-)
create mode 100644 app/api/version/route.ts
create mode 100644 components/__tests__/app-update-prompt.test.tsx
create mode 100644 components/app-update-prompt.tsx
create mode 100644 lib/__tests__/app-update.test.ts
create mode 100644 lib/__tests__/sw-build-caches.test.ts
create mode 100644 lib/__tests__/sw-harness.ts
create mode 100644 lib/app-update.ts
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 6797b59ec..8d5589501 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -25,6 +25,7 @@ import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"
import { PluginAppEvents } from "@/components/providers/plugin-app-events";
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
+import { AppUpdatePrompt } from "@/components/app-update-prompt";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
@@ -98,6 +99,7 @@ export default async function LocaleLayout({
+
{/* Lite is a static bundle: no server keeps an archive there. */}
diff --git a/app/api/version/route.ts b/app/api/version/route.ts
new file mode 100644
index 000000000..2352aec79
--- /dev/null
+++ b/app/api/version/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * The id of the build this server runs (next.config.ts BULWARK_BUILD_ID),
+ * for open tabs to notice a deploy (#523, components/app-update-prompt.tsx).
+ * Only the id: a hash that says nothing about the version or the commit,
+ * and the same id every page of the build carries anyway.
+ */
+export function GET() {
+ return NextResponse.json(
+ { buildId: process.env.NEXT_PUBLIC_BUILD_ID ?? '' },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+}
diff --git a/components/__tests__/app-update-prompt.test.tsx b/components/__tests__/app-update-prompt.test.tsx
new file mode 100644
index 000000000..28b2bedb3
--- /dev/null
+++ b/components/__tests__/app-update-prompt.test.tsx
@@ -0,0 +1,83 @@
+import { act, render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/hooks/use-config", () => ({ useConfig: () => ({ appName: "Webmail" }) }));
+
+const update = vi.hoisted(() => ({
+ newerBuildId: vi.fn<() => Promise>(),
+ reloadToNewBuild: vi.fn(async () => {}),
+}));
+vi.mock("@/lib/app-update", async (importOriginal) => ({
+ ...(await importOriginal()),
+ newerBuildId: update.newerBuildId,
+ reloadToNewBuild: update.reloadToNewBuild,
+}));
+
+import { AppUpdatePrompt, UPDATE_CHECK_MS } from "../app-update-prompt";
+import { useToastStore } from "@/stores/toast-store";
+
+const toasts = () => useToastStore.getState().toasts;
+
+async function flush() {
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+describe("AppUpdatePrompt", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ useToastStore.setState({ toasts: [] });
+ update.newerBuildId.mockReset();
+ update.reloadToNewBuild.mockClear();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("offers a reload once the server runs another build", async () => {
+ update.newerBuildId.mockResolvedValue(null);
+ render();
+ await act(async () => { vi.advanceTimersByTime(UPDATE_CHECK_MS); });
+ await flush();
+ expect(update.newerBuildId).toHaveBeenCalledTimes(1);
+ expect(toasts()).toHaveLength(0);
+
+ update.newerBuildId.mockResolvedValue("build-b");
+ await act(async () => { vi.advanceTimersByTime(UPDATE_CHECK_MS); });
+ await flush();
+ expect(toasts()).toHaveLength(1);
+ expect(toasts()[0].title).toBe("ready");
+ expect(toasts()[0].action?.label).toBe("reload");
+ toasts()[0].action?.onClick();
+ expect(update.reloadToNewBuild).toHaveBeenCalled();
+
+ // The same build isn't offered twice.
+ await act(async () => { vi.advanceTimersByTime(UPDATE_CHECK_MS); });
+ await flush();
+ expect(toasts()).toHaveLength(1);
+ });
+
+ it("checks when a chunk of this build fails to load", async () => {
+ update.newerBuildId.mockResolvedValue("build-b");
+ render();
+ const script = document.createElement("script");
+ script.src = "http://localhost/_next/static/chunks/gone.js";
+ document.body.appendChild(script);
+ await act(async () => { script.dispatchEvent(new Event("error")); });
+ await flush();
+ expect(update.newerBuildId).toHaveBeenCalledTimes(1);
+ expect(toasts()).toHaveLength(1);
+ });
+
+ it("checks on focus, at most once a minute", async () => {
+ update.newerBuildId.mockResolvedValue(null);
+ render();
+ await act(async () => { window.dispatchEvent(new Event("focus")); });
+ expect(update.newerBuildId).not.toHaveBeenCalled();
+ await act(async () => { vi.advanceTimersByTime(61_000); window.dispatchEvent(new Event("focus")); });
+ await flush();
+ expect(update.newerBuildId).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/components/app-update-prompt.tsx b/components/app-update-prompt.tsx
new file mode 100644
index 000000000..3f84183f9
--- /dev/null
+++ b/components/app-update-prompt.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useTranslations } from "next-intl";
+import { useConfig } from "@/hooks/use-config";
+import { productName } from "@/lib/product-name";
+import { isChunkLoadFailure, newerBuildId, reloadToNewBuild } from "@/lib/app-update";
+import { toast } from "@/stores/toast-store";
+
+/** How often an open tab asks whether a new build was deployed. */
+export const UPDATE_CHECK_MS = 15 * 60 * 1000;
+/** Focus and tab switches ask at most this often. */
+const FOCUS_THROTTLE_MS = 60 * 1000;
+
+/**
+ * "A new version of Bulwark is ready. Reload" (#523): once the server runs
+ * another build than this tab, on focus, every 15 minutes, and right away
+ * when a chunk of this tab's build fails to load (it is gone after a
+ * deploy). Shown once per new build; with offline mode off as well as on.
+ */
+export function AppUpdatePrompt() {
+ const t = useTranslations("app_update");
+ const appName = productName(useConfig().appName);
+ const text = useRef({ ready: "", reload: "" });
+ text.current = { ready: t("ready", { appName }), reload: t("reload") };
+
+ useEffect(() => {
+ let shownFor: string | null = null;
+ let lastCheck = 0;
+ let checking = false;
+ const check = async (force: boolean) => {
+ if (typeof document !== "undefined" && document.visibilityState === "hidden" && !force) return;
+ const now = Date.now();
+ if (checking || (!force && now - lastCheck < FOCUS_THROTTLE_MS)) return;
+ checking = true;
+ lastCheck = now;
+ try {
+ const id = await newerBuildId();
+ if (!id || id === shownFor) return;
+ shownFor = id;
+ toast.info(text.current.ready, {
+ duration: 0,
+ action: { label: text.current.reload, onClick: () => { void reloadToNewBuild(); } },
+ });
+ } finally {
+ checking = false;
+ }
+ };
+ const onFocus = () => { void check(false); };
+ const onLoadFailure = (event: Event) => {
+ if (isChunkLoadFailure(event)) void check(true);
+ };
+ lastCheck = Date.now();
+ document.addEventListener("visibilitychange", onFocus);
+ window.addEventListener("focus", onFocus);
+ window.addEventListener("error", onLoadFailure, true);
+ window.addEventListener("unhandledrejection", onLoadFailure);
+ const interval = setInterval(() => { void check(true); }, UPDATE_CHECK_MS);
+ return () => {
+ document.removeEventListener("visibilitychange", onFocus);
+ window.removeEventListener("focus", onFocus);
+ window.removeEventListener("error", onLoadFailure, true);
+ window.removeEventListener("unhandledrejection", onLoadFailure);
+ clearInterval(interval);
+ };
+ }, []);
+
+ return null;
+}
diff --git a/components/service-worker-registration.tsx b/components/service-worker-registration.tsx
index e5660c24e..87310a67a 100644
--- a/components/service-worker-registration.tsx
+++ b/components/service-worker-registration.tsx
@@ -2,8 +2,7 @@
import { useEffect } from "react";
import { IS_LITE } from "@/lib/lite";
-
-const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
+import { announceBuildToServiceWorker, serviceWorkerUrl, SW_SCOPE } from "@/lib/app-update";
export function ServiceWorkerRegistration() {
useEffect(() => {
@@ -31,14 +30,21 @@ export function ServiceWorkerRegistration() {
return;
}
+ // With this build's id in the URL, a deploy installs a new worker, which
+ // keeps its caches apart from the old build's (public/sw.js).
navigator.serviceWorker
- .register(`${BASE_PATH}/sw.js`, { scope: `${BASE_PATH}/` })
+ .register(serviceWorkerUrl(), { scope: SW_SCOPE })
.then((registration) => {
console.log("Service Worker registered successfully:", registration);
+ announceBuildToServiceWorker();
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
+ // A worker that takes over later (after a deploy) learns this tab's build too.
+ const onControllerChange = () => announceBuildToServiceWorker();
+ navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
+ return () => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
}, []);
return null;
diff --git a/lib/__tests__/app-update.test.ts b/lib/__tests__/app-update.test.ts
new file mode 100644
index 000000000..9ac425623
--- /dev/null
+++ b/lib/__tests__/app-update.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const lite = vi.hoisted(() => ({ IS_LITE: false, LITE_BUILD_ID: '' }));
+vi.mock('@/lib/lite', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ get IS_LITE() { return lite.IS_LITE; },
+ get LITE_BUILD_ID() { return lite.LITE_BUILD_ID; },
+ };
+});
+
+import { currentBuildId, fetchDeployedBuildId, isChunkLoadFailure, newerBuildId, serviceWorkerUrl } from '../app-update';
+
+function answer(body: unknown, ok = true) {
+ return vi.fn(async () => ({ ok, json: async () => body }) as Response);
+}
+
+describe('app update', () => {
+ beforeEach(() => {
+ vi.stubEnv('NEXT_PUBLIC_BUILD_ID', 'build-a');
+ lite.IS_LITE = false;
+ lite.LITE_BUILD_ID = '';
+ });
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ });
+
+ it('registers the service worker with the build id', () => {
+ expect(currentBuildId()).toBe('build-a');
+ expect(serviceWorkerUrl()).toBe('/sw.js?v=build-a');
+ });
+
+ it('asks /api/version which build the server runs', async () => {
+ const fetchMock = answer({ buildId: 'build-b' });
+ vi.stubGlobal('fetch', fetchMock);
+ expect(await fetchDeployedBuildId()).toBe('build-b');
+ expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe('/api/version');
+ expect(await newerBuildId()).toBe('build-b');
+ });
+
+ it('offers nothing while the server runs this build, or cannot be asked', async () => {
+ vi.stubGlobal('fetch', answer({ buildId: 'build-a' }));
+ expect(await newerBuildId()).toBeNull();
+ vi.stubGlobal('fetch', answer({}, false));
+ expect(await newerBuildId()).toBeNull();
+ vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); }));
+ expect(await newerBuildId()).toBeNull();
+ });
+
+ it('reads lite-build.json in Lite', async () => {
+ lite.IS_LITE = true;
+ lite.LITE_BUILD_ID = '1.20.0-abc-x1';
+ const fetchMock = answer({ buildId: '1.20.0-def-x2' });
+ vi.stubGlobal('fetch', fetchMock);
+ expect(currentBuildId()).toBe('1.20.0-abc-x1');
+ expect(await newerBuildId()).toBe('1.20.0-def-x2');
+ expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toMatch(/^\/lite-build\.json\?t=\d+$/);
+ });
+
+ it('recognises a chunk of a build that is gone', () => {
+ const script = document.createElement('script');
+ script.src = 'https://mail.example.com/_next/static/chunks/abc.js';
+ expect(isChunkLoadFailure({ target: script } as unknown as Event)).toBe(true);
+ const img = document.createElement('img');
+ img.src = 'https://mail.example.com/_next/static/media/x.png';
+ expect(isChunkLoadFailure({ target: img } as unknown as Event)).toBe(false);
+ const other = document.createElement('script');
+ other.src = 'https://cdn.example.com/x.js';
+ expect(isChunkLoadFailure({ target: other } as unknown as Event)).toBe(false);
+ const error = Object.assign(new Error('Loading chunk 123 failed.'), { name: 'ChunkLoadError' });
+ expect(isChunkLoadFailure({ reason: error } as unknown as PromiseRejectionEvent)).toBe(true);
+ expect(isChunkLoadFailure({ reason: new Error('nope') } as unknown as PromiseRejectionEvent)).toBe(false);
+ });
+});
diff --git a/lib/__tests__/sw-build-caches.test.ts b/lib/__tests__/sw-build-caches.test.ts
new file mode 100644
index 000000000..0350c5c6c
--- /dev/null
+++ b/lib/__tests__/sw-build-caches.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import { FakeCaches, loadWorker } from './sw-harness';
+
+// The service worker names its caches after the build it came with (#523)
+// and deletes a build's cache once no open tab runs that build.
+describe('service worker caches per build', () => {
+ it('keeps the caches of builds an open tab runs and deletes the rest', async () => {
+ const caches = new FakeCaches();
+ for (const name of ['bulwark-shell-a', 'bulwark-shell-b', 'bulwark-shell-c', 'bulwark-push-state-v1']) await caches.open(name);
+ const clients = [{ id: 'tab-1' }, { id: 'tab-2' }];
+ const worker = loadWorker({ buildId: 'c', caches, clients });
+ // tab-1 still runs build a; tab-2 already runs c.
+ await worker.dispatch('message', { data: { type: 'client-build', buildId: 'a' }, source: { id: 'tab-1' } });
+ await worker.dispatch('message', { data: { type: 'client-build', buildId: 'c' }, source: { id: 'tab-2' } });
+ expect((await caches.keys()).sort()).toEqual(['bulwark-push-state-v1', 'bulwark-shell-a', 'bulwark-shell-c', 'bulwark-sw-state-v1']);
+
+ // tab-1 reloads into build c: build a's files can go.
+ await worker.dispatch('message', { data: { type: 'client-build', buildId: 'c' }, source: { id: 'tab-1' } });
+ expect(await caches.has('bulwark-shell-a')).toBe(false);
+ expect(await caches.has('bulwark-shell-c')).toBe(true);
+ // Push state isn't a build's cache.
+ expect(await caches.has('bulwark-push-state-v1')).toBe(true);
+ });
+
+ it('a new build activated after a deploy keeps the old build while its tab is open', async () => {
+ const caches = new FakeCaches();
+ const clients = [{ id: 'tab-1' }];
+ const old = loadWorker({ buildId: 'a', caches, clients });
+ await caches.open('bulwark-shell-a');
+ await old.dispatch('message', { data: { type: 'client-build', buildId: 'a' }, source: { id: 'tab-1' } });
+
+ const next = loadWorker({ buildId: 'b', caches, clients });
+ await next.dispatch('activate');
+ expect(await caches.has('bulwark-shell-a')).toBe(true);
+
+ // The tab is closed: the next activation removes build a's files.
+ clients.length = 0;
+ await next.dispatch('activate');
+ expect(await caches.has('bulwark-shell-a')).toBe(false);
+ });
+
+ it('takes over when the tab asks for it', async () => {
+ const worker = loadWorker({ buildId: 'b', caches: new FakeCaches(), clients: [] });
+ const before = worker.skipped();
+ await worker.dispatch('message', { data: { type: 'skip-waiting' } });
+ expect(worker.skipped()).toBe(before + 1);
+ });
+});
diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts
new file mode 100644
index 000000000..76bda4eb7
--- /dev/null
+++ b/lib/__tests__/sw-harness.ts
@@ -0,0 +1,50 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import vm from 'node:vm';
+
+// A stand-in service worker global for public/sw.js (#523): a Cache API in
+// memory and the events the worker listens to.
+const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
+
+export class FakeCaches {
+ stores = new Map>();
+ async open(name: string) {
+ if (!this.stores.has(name)) this.stores.set(name, new Map());
+ const store = this.stores.get(name)!;
+ const keyOf = (req: string | { url: string }) => (typeof req === 'string' ? req : req.url);
+ return {
+ match: async (req: string | { url: string }) => store.get(keyOf(req))?.clone(),
+ put: async (req: string | { url: string }, res: Response) => { store.set(keyOf(req), res); },
+ delete: async (req: string | { url: string }) => store.delete(keyOf(req)),
+ keys: async () => [...store.keys()].map((url) => ({ url })),
+ };
+ }
+ async keys() { return [...this.stores.keys()]; }
+ async delete(name: string) { return this.stores.delete(name); }
+ async has(name: string) { return this.stores.has(name); }
+ async match(req: string | { url: string }) {
+ const key = typeof req === 'string' ? req : req.url;
+ for (const store of this.stores.values()) if (store.has(key)) return store.get(key)!.clone();
+ return undefined;
+ }
+}
+
+export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }> }) {
+ const listeners = new Map void>();
+ let skipped = 0;
+ const self = {
+ location: { href: `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' },
+ addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
+ skipWaiting: () => { skipped++; },
+ clients: { claim: async () => {}, matchAll: async () => opts.clients },
+ registration: { showNotification: async () => {}, getNotifications: async () => [] },
+ };
+ const context = vm.createContext({ self, caches: opts.caches, fetch: async () => { throw new TypeError('offline'); }, URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout });
+ vm.runInContext(SW_SOURCE, context);
+ async function dispatch(type: string, event: Record = {}) {
+ const waits: Promise[] = [];
+ listeners.get(type)?.({ ...event, waitUntil: (p: Promise) => waits.push(p) });
+ await Promise.all(waits);
+ }
+ return { dispatch, listeners, skipped: () => skipped };
+}
diff --git a/lib/app-update.ts b/lib/app-update.ts
new file mode 100644
index 000000000..01dc21954
--- /dev/null
+++ b/lib/app-update.ts
@@ -0,0 +1,103 @@
+/**
+ * Noticing a deploy in a tab that stays open (#523). Every build has an id
+ * (next.config.ts BULWARK_BUILD_ID; Lite: NEXT_PUBLIC_LITE_BUILD_ID). The
+ * server says which build it runs now (/api/version; Lite: lite-build.json),
+ * and a tab whose own build differs offers a reload
+ * (components/app-update-prompt.tsx).
+ *
+ * The service worker is registered as sw.js?v=, so a deploy also
+ * installs a new worker, which keeps its caches apart from the old build's
+ * (public/sw.js).
+ */
+
+import { apiFetch } from '@/lib/browser-navigation';
+import { IS_LITE, LITE_BUILD_ID } from '@/lib/lite';
+
+/** The build this tab runs; empty where there is none (tests). */
+export function currentBuildId(): string {
+ return (IS_LITE ? LITE_BUILD_ID : process.env.NEXT_PUBLIC_BUILD_ID) ?? '';
+}
+
+const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
+
+/** Where the service worker is registered, with this build's id. */
+export const SW_SCOPE = `${BASE_PATH}/`;
+
+export function serviceWorkerUrl(): string {
+ const id = currentBuildId();
+ return `${BASE_PATH}/sw.js${id ? `?v=${encodeURIComponent(id)}` : ''}`;
+}
+
+/** The build the server serves now, or null when it can't be asked (offline, an old server). */
+export async function fetchDeployedBuildId(): Promise {
+ try {
+ // lite-build.json is a static file, possibly behind an immutable cache:
+ // a fresh query string gets past every cache on the way.
+ const res = IS_LITE
+ ? await apiFetch(`/lite-build.json?t=${Date.now()}`, { cache: 'no-store' })
+ : await apiFetch('/api/version', { cache: 'no-store' });
+ if (!res.ok) return null;
+ const data: unknown = await res.json();
+ const id = data && typeof data === 'object' ? (data as { buildId?: unknown }).buildId : null;
+ return typeof id === 'string' && id ? id : null;
+ } catch {
+ return null;
+ }
+}
+
+/** The id of a newer build than this tab's, when the server runs one. */
+export async function newerBuildId(): Promise {
+ const current = currentBuildId();
+ if (!current) return null;
+ const deployed = await fetchDeployedBuildId();
+ return deployed && deployed !== current ? deployed : null;
+}
+
+/**
+ * Loads the new build. A new service worker that waits for the old build's
+ * tabs to close (it does while offline mode keeps the old build's files) is
+ * told to take over first, so the reload isn't answered by the old worker.
+ */
+export async function reloadToNewBuild(): Promise {
+ try {
+ const registration = typeof navigator !== 'undefined' && 'serviceWorker' in navigator
+ ? await navigator.serviceWorker.getRegistration(SW_SCOPE)
+ : undefined;
+ const waiting = registration?.waiting;
+ if (waiting) {
+ await new Promise((resolve) => {
+ const timer = setTimeout(resolve, 3000);
+ navigator.serviceWorker.addEventListener('controllerchange', () => { clearTimeout(timer); resolve(); }, { once: true });
+ waiting.postMessage({ type: 'skip-waiting' });
+ });
+ }
+ } catch {
+ // Reload anyway: the network answers.
+ }
+ window.location.reload();
+}
+
+/** Tells the service worker which build this tab runs, so it keeps that build's files while the tab is open. */
+export function announceBuildToServiceWorker(): void {
+ const id = currentBuildId();
+ if (!id || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
+ void navigator.serviceWorker.ready
+ .then((registration) => {
+ (navigator.serviceWorker.controller ?? registration.active)?.postMessage({ type: 'client-build', buildId: id });
+ })
+ .catch(() => {});
+}
+
+/** Whether a failed script or chunk load says this tab's build is gone from the server. */
+export function isChunkLoadFailure(event: Event | PromiseRejectionEvent): boolean {
+ if ('reason' in event) {
+ const reason = event.reason as { name?: unknown; message?: unknown } | null;
+ const name = typeof reason?.name === 'string' ? reason.name : '';
+ const message = typeof reason?.message === 'string' ? reason.message : '';
+ return name === 'ChunkLoadError' || /Loading chunk [\w-]+ failed|Failed to load chunk|error loading dynamically imported module|Importing a module script failed/i.test(message);
+ }
+ const target = event.target as { tagName?: unknown; src?: unknown; href?: unknown } | null;
+ if (!target || (target.tagName !== 'SCRIPT' && target.tagName !== 'LINK')) return false;
+ const url = typeof target.src === 'string' && target.src ? target.src : typeof target.href === 'string' ? target.href : '';
+ return url.includes('/_next/static/');
+}
diff --git a/lib/web-push.ts b/lib/web-push.ts
index 01d9486ee..f1739b441 100644
--- a/lib/web-push.ts
+++ b/lib/web-push.ts
@@ -11,6 +11,7 @@ import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts';
import { noThirdPartyTraffic } from '@/lib/third-party-mode';
import { IS_LITE } from '@/lib/lite';
+import { serviceWorkerUrl, SW_SCOPE } from '@/lib/app-update';
import {
EMPTY_NOTIFICATION_MUTES,
isAccountMuted,
@@ -53,9 +54,6 @@ function emailPushRefusedKey(accountId: string): string {
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
}
-const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
-const SW_SCOPE = `${BASE_PATH}/`;
-const SW_URL = `${BASE_PATH}/sw.js`;
// Re-exported for callers that already import the relay default from here.
// The relay list itself lives in lib/push-relays.ts.
@@ -553,7 +551,7 @@ async function ensureServiceWorker(): Promise {
// push handler is in place.
let registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
if (!registration) {
- registration = await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE });
+ registration = await navigator.serviceWorker.register(serviceWorkerUrl(), { scope: SW_SCOPE });
}
await navigator.serviceWorker.ready;
return registration;
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 9f6f52300..6acab4ae2 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "عدم التذكير مرة أخرى",
"dismiss_aria": "تجاهل مطالبة التثبيت"
},
+ "app_update": {
+ "ready": "إصدار جديد من {appName} جاهز.",
+ "reload": "إعادة التحميل"
+ },
"deep_link": {
"message_not_found": "لم تعد هذه الرسالة متاحة.",
"thread_not_found": "لم تعد هذه المحادثة متاحة.",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 9fc7e876d..bbeb6b78f 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "No m'ho tornis a recordar",
"dismiss_aria": "Descarta l'avís d'instal·lació"
},
+ "app_update": {
+ "ready": "Hi ha una versió nova de {appName} a punt.",
+ "reload": "Torna a carregar"
+ },
"deep_link": {
"message_not_found": "Aquest missatge ja no està disponible.",
"thread_not_found": "Aquesta conversa ja no està disponible.",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 525d1a337..21c3a10df 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Už mi to nepřipomínat",
"dismiss_aria": "Zavřít výzvu k instalaci"
},
+ "app_update": {
+ "ready": "Je připravena nová verze aplikace {appName}.",
+ "reload": "Načíst znovu"
+ },
"deep_link": {
"message_not_found": "Tato zpráva už není dostupná.",
"thread_not_found": "Tato konverzace už není dostupná.",
diff --git a/locales/da/common.json b/locales/da/common.json
index 4ea9828dc..dbe498dd8 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Påmind mig ikke igen",
"dismiss_aria": "Afvis installationsprompt"
},
+ "app_update": {
+ "ready": "En ny version af {appName} er klar.",
+ "reload": "Genindlæs"
+ },
"deep_link": {
"message_not_found": "Denne besked er ikke længere tilgængelig.",
"thread_not_found": "Denne samtale er ikke længere tilgængelig.",
diff --git a/locales/de/common.json b/locales/de/common.json
index 10af211ed..88505795b 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Nicht mehr erinnern",
"dismiss_aria": "Installationshinweis schließen"
},
+ "app_update": {
+ "ready": "Eine neue Version von {appName} ist bereit.",
+ "reload": "Neu laden"
+ },
"deep_link": {
"message_not_found": "Diese Nachricht ist nicht mehr verfügbar.",
"thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.",
diff --git a/locales/en/common.json b/locales/en/common.json
index 0948ad0c5..2f6788882 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Don't remind me again",
"dismiss_aria": "Dismiss install prompt"
},
+ "app_update": {
+ "ready": "A new version of {appName} is ready.",
+ "reload": "Reload"
+ },
"deep_link": {
"message_not_found": "This message is no longer available.",
"thread_not_found": "This conversation is no longer available.",
diff --git a/locales/es/common.json b/locales/es/common.json
index 6b322b3fe..26ed6b6ee 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "No volver a recordármelo",
"dismiss_aria": "Cerrar aviso de instalación"
},
+ "app_update": {
+ "ready": "Hay una nueva versión de {appName} lista.",
+ "reload": "Recargar"
+ },
"deep_link": {
"message_not_found": "Este mensaje ya no está disponible.",
"thread_not_found": "Esta conversación ya no está disponible.",
diff --git a/locales/et/common.json b/locales/et/common.json
index a4097eb3c..c05e964fa 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Ära tuleta enam meelde",
"dismiss_aria": "Sulge paigaldusteade"
},
+ "app_update": {
+ "ready": "{appName} uus versioon on valmis.",
+ "reload": "Laadi uuesti"
+ },
"deep_link": {
"message_not_found": "See sõnum ei ole enam saadaval.",
"thread_not_found": "See vestlus ei ole enam saadaval.",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 439ece3b2..1d77d3504 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "دیگر یادآوری نکن",
"dismiss_aria": "رد کردن پیشنهاد نصب"
},
+ "app_update": {
+ "ready": "نسخهٔ تازهای از {appName} آماده است.",
+ "reload": "بارگیری دوباره"
+ },
"deep_link": {
"message_not_found": "این پیام دیگر در دسترس نیست.",
"thread_not_found": "این گفتگو دیگر در دسترس نیست.",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 5ca8790e1..700a8c195 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Ne plus me le rappeler",
"dismiss_aria": "Fermer l'invite d'installation"
},
+ "app_update": {
+ "ready": "Une nouvelle version de {appName} est prête.",
+ "reload": "Recharger"
+ },
"deep_link": {
"message_not_found": "Ce message n'est plus disponible.",
"thread_not_found": "Cette conversation n'est plus disponible.",
diff --git a/locales/he/common.json b/locales/he/common.json
index bdddb3038..b089c9b13 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -5634,6 +5634,10 @@
"dont_remind": "אל תזכיר לי שוב",
"dismiss_aria": "בטל הודעת התקנה"
},
+ "app_update": {
+ "ready": "גרסה חדשה של {appName} מוכנה.",
+ "reload": "טעינה מחדש"
+ },
"deep_link": {
"message_not_found": "הודעה זו אינה זמינה עוד.",
"thread_not_found": "שיחה זו אינה זמינה עוד.",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 09c5ac72e..aabad0b4b 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Ne emlékeztess többet",
"dismiss_aria": "Telepítési ablak elutasítása"
},
+ "app_update": {
+ "ready": "Elkészült a(z) {appName} új verziója.",
+ "reload": "Újratöltés"
+ },
"deep_link": {
"message_not_found": "Ez az üzenet már nem érhető el.",
"thread_not_found": "Ez a beszélgetés már nem érhető el.",
diff --git a/locales/it/common.json b/locales/it/common.json
index e4488bfb3..2aa4ce3e6 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Non ricordarmelo più",
"dismiss_aria": "Chiudi avviso di installazione"
},
+ "app_update": {
+ "ready": "È pronta una nuova versione di {appName}.",
+ "reload": "Ricarica"
+ },
"deep_link": {
"message_not_found": "Questo messaggio non è più disponibile.",
"thread_not_found": "Questa conversazione non è più disponibile.",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 600717974..382121856 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "今後表示しない",
"dismiss_aria": "インストールプロンプトを閉じる"
},
+ "app_update": {
+ "ready": "{appName} の新しいバージョンを利用できます。",
+ "reload": "再読み込み"
+ },
"deep_link": {
"message_not_found": "このメッセージは利用できなくなりました。",
"thread_not_found": "この会話は利用できなくなりました。",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index e2f1c777f..48cdfff29 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "다시 알리지 않음",
"dismiss_aria": "설치 프롬프트 닫기"
},
+ "app_update": {
+ "ready": "{appName}의 새 버전이 준비되었습니다.",
+ "reload": "새로고침"
+ },
"deep_link": {
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
"thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index d6e4e6773..7ff55ae18 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Vairs man neatgādināt",
"dismiss_aria": "Aizvērt instalēšanas paziņojumu"
},
+ "app_update": {
+ "ready": "Ir gatava jauna {appName} versija.",
+ "reload": "Pārlādēt"
+ },
"deep_link": {
"message_not_found": "Šī ziņa vairs nav pieejama.",
"thread_not_found": "Šī sarakste vairs nav pieejama.",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index abd1cdb3c..15e4d912b 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Дахиж сануулахгүй",
"dismiss_aria": "Суулгах сануулгыг хаах"
},
+ "app_update": {
+ "ready": "{appName}-ийн шинэ хувилбар бэлэн боллоо.",
+ "reload": "Дахин ачаалах"
+ },
"deep_link": {
"message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.",
"thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 95c568016..b935ae7a4 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Ikke minn meg på det igjen",
"dismiss_aria": "Lukk forespørselen om installering"
},
+ "app_update": {
+ "ready": "En ny versjon av {appName} er klar.",
+ "reload": "Last inn på nytt"
+ },
"deep_link": {
"message_not_found": "Denne meldingen er ikke lenger tilgjengelig.",
"thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index e33903eed..ac95f12ea 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Niet meer herinneren",
"dismiss_aria": "Installatiemelding sluiten"
},
+ "app_update": {
+ "ready": "Er is een nieuwe versie van {appName} klaar.",
+ "reload": "Opnieuw laden"
+ },
"deep_link": {
"message_not_found": "Dit bericht is niet meer beschikbaar.",
"thread_not_found": "Dit gesprek is niet meer beschikbaar.",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index d155b2089..4513c93da 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Nie przypominaj mi więcej",
"dismiss_aria": "Zamknij monit instalacji"
},
+ "app_update": {
+ "ready": "Nowa wersja {appName} jest gotowa.",
+ "reload": "Odśwież"
+ },
"deep_link": {
"message_not_found": "Ta wiadomość nie jest już dostępna.",
"thread_not_found": "Ta rozmowa nie jest już dostępna.",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index a7976de37..3ad7c84c3 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Não lembrar novamente",
"dismiss_aria": "Dispensar aviso de instalação"
},
+ "app_update": {
+ "ready": "Uma nova versão do {appName} está pronta.",
+ "reload": "Recarregar"
+ },
"deep_link": {
"message_not_found": "Esta mensagem não está mais disponível.",
"thread_not_found": "Esta conversa não está mais disponível.",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 9ef1ddad9..fea3755a0 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Nu-mi mai reaminti",
"dismiss_aria": "Ignorați solicitarea de instalare"
},
+ "app_update": {
+ "ready": "O versiune nouă de {appName} este gata.",
+ "reload": "Reîncarcă"
+ },
"deep_link": {
"message_not_found": "Acest mesaj nu mai este disponibil.",
"thread_not_found": "Această conversație nu mai este disponibilă.",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 75e7880ef..30b032d35 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Больше не напоминать",
"dismiss_aria": "Закрыть запрос на установку"
},
+ "app_update": {
+ "ready": "Готова новая версия {appName}.",
+ "reload": "Перезагрузить"
+ },
"deep_link": {
"message_not_found": "Это письмо больше недоступно.",
"thread_not_found": "Эта переписка больше недоступна.",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index e33363aef..f5af5870d 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Viac mi to nepripomínať",
"dismiss_aria": "Zavrieť výzvu na inštaláciu"
},
+ "app_update": {
+ "ready": "Je pripravená nová verzia aplikácie {appName}.",
+ "reload": "Načítať znova"
+ },
"deep_link": {
"message_not_found": "Táto správa už nie je dostupná.",
"thread_not_found": "Táto konverzácia už nie je dostupná.",
diff --git a/locales/th/common.json b/locales/th/common.json
index 0d5920696..f65cf0c9a 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -2017,6 +2017,10 @@
"dont_remind": "ไม่ต้องแจ้งเตือนอีก",
"dismiss_aria": "ปิดการแจ้งเตือนการติดตั้ง"
},
+ "app_update": {
+ "ready": "{appName} เวอร์ชันใหม่พร้อมแล้ว",
+ "reload": "โหลดใหม่"
+ },
"plugin_pages": {
"loading": "กำลังโหลดหน้าเว็บ...",
"not_available_title": "หน้านี้ไม่พร้อมใช้งาน",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index e255362f3..c505807aa 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Bir daha hatırlatma",
"dismiss_aria": "Yükleme istemini kapat"
},
+ "app_update": {
+ "ready": "{appName} uygulamasının yeni sürümü hazır.",
+ "reload": "Yeniden yükle"
+ },
"deep_link": {
"message_not_found": "Bu ileti artık kullanılamıyor.",
"thread_not_found": "Bu sohbet artık kullanılamıyor.",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 9893f8f60..e9ec62a01 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "Більше не нагадувати",
"dismiss_aria": "Закрити запит на встановлення"
},
+ "app_update": {
+ "ready": "Нова версія {appName} готова.",
+ "reload": "Перезавантажити"
+ },
"deep_link": {
"message_not_found": "Цей лист більше недоступний.",
"thread_not_found": "Ця розмова більше недоступна.",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 55aa099d0..43bb724a3 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -5645,6 +5645,10 @@
"dont_remind": "Đừng nhắc tôi nữa",
"dismiss_aria": "Loại bỏ lời nhắc cài đặt"
},
+ "app_update": {
+ "ready": "Đã có phiên bản mới của {appName}.",
+ "reload": "Tải lại"
+ },
"deep_link": {
"message_not_found": "Thư này không còn nữa.",
"thread_not_found": "Cuộc trò chuyện này không còn tồn tại nữa.",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 0525a4572..a8f6e1461 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "不再提醒",
"dismiss_aria": "關閉安裝提示"
},
+ "app_update": {
+ "ready": "{appName} 新版本已準備就緒。",
+ "reload": "重新載入"
+ },
"deep_link": {
"message_not_found": "已無法使用此郵件。",
"thread_not_found": "已無法使用此對話串。",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 6dbeb68ca..be86a0db4 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -5633,6 +5633,10 @@
"dont_remind": "不再提醒",
"dismiss_aria": "关闭安装提示"
},
+ "app_update": {
+ "ready": "{appName} 新版本已就绪。",
+ "reload": "重新加载"
+ },
"deep_link": {
"message_not_found": "此邮件已不可用。",
"thread_not_found": "此会话已不可用。",
diff --git a/next.config.ts b/next.config.ts
index e8cfc98f7..bd5ae344e 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import { execSync } from "child_process";
+import { createHash } from "crypto";
import { readFileSync } from "fs";
import { join } from "path";
@@ -27,6 +28,20 @@ try {
// VERSION file not found
}
+// One id per build (#523): the service worker names its caches after it, and
+// open tabs compare it with /api/version to learn that a new build was
+// deployed. A hash of the commit and the build time, so it doesn't tell
+// anyone which commit runs. The config is read again by every process the
+// build starts; the first one sets the variable and the others inherit it,
+// so all of them use the same id.
+if (!process.env.BULWARK_BUILD_ID) {
+ process.env.BULWARK_BUILD_ID = createHash("sha256")
+ .update(`${gitCommitHash}:${appVersion}:${Date.now()}:${Math.random()}`)
+ .digest("base64url")
+ .slice(0, 16);
+}
+const buildId = process.env.BULWARK_BUILD_ID;
+
// Subpath deployment, e.g. NEXT_PUBLIC_BASE_PATH=/webmail. Read at build time
// because Next.js bakes basePath into emitted asset URLs and route metadata.
// Trailing slash is stripped; an empty/missing value disables the feature.
@@ -52,6 +67,7 @@ const nextConfig: NextConfig = {
// No `X-Powered-By: Next.js`; headers() below sends
// `X-Powered-By: bulwarkmail.org` instead, without a version.
poweredByHeader: false,
+ generateBuildId: async () => buildId,
// Keep proxy rewrites on the origin the server listens on. Next renames a
// 127.x or [::1] host to "localhost" in each proxy rewrite, but its router
// compares the rewrite with the listen address. On HOSTNAME=127.0.0.1 every
@@ -144,6 +160,7 @@ const nextConfig: NextConfig = {
},
env: {
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
+ NEXT_PUBLIC_BUILD_ID: buildId,
// A dev server may announce another version, e.g. to try a plugin that needs the next release.
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || appVersion,
NEXT_PUBLIC_BASE_PATH: basePath,
diff --git a/public/sw.js b/public/sw.js
index 1c9bb3bfb..8551508a1 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -3,12 +3,18 @@
// Bulwark service worker.
//
// This SW does two jobs:
-// 1. Satisfy the PWA installability requirement (network-only fetch handler,
-// no caching - so we never serve stale chunks after a deployment).
+// 1. Satisfy the PWA installability requirement (a fetch handler that
+// leaves every request to the network).
// 2. Receive Web Push wake-up pings from the relay and turn them into
// enriched system notifications. Mirrors the React Native FCM headless
// task: relay sends only a state-change ping, the client fetches the
// newest unread email itself so the relay never sees mail content.
+//
+// Builds (#523): the page registers sw.js?v=, so every deploy
+// installs a new worker that knows its build. Anything it caches goes into
+// caches named after that build (bulwark-shell-), never into one
+// cache that outlives a deploy, so a tab is never handed chunks of another
+// build. A build's cache is deleted once no open tab runs that build.
// When the app is mounted at a subpath (Next.js basePath, e.g. /webmail), the
// SW is served at /webmail/sw.js and registered with scope /webmail/. Derive
@@ -25,14 +31,79 @@ function getBasePath() {
const BASE_PATH = getBasePath();
const MAILTO_CLIENTS = new Map();
+// The build this worker came with (the ?v= the page registered it with).
+const BUILD_ID = new URL(self.location.href).searchParams.get("v") || "unversioned";
+const SHELL_CACHE_PREFIX = "bulwark-shell-";
+const SHELL_CACHE = SHELL_CACHE_PREFIX + BUILD_ID;
+// What the worker has to remember across restarts and across builds: which
+// build each open tab runs. In the Cache API like the push state, under its
+// own name.
+const SW_STATE_CACHE = "bulwark-sw-state-v1";
+const CLIENT_BUILDS_KEY = "/__bulwark/client-builds";
+
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
- event.waitUntil(self.clients.claim());
+ event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches()]));
});
+async function readClientBuilds() {
+ try {
+ const cache = await caches.open(SW_STATE_CACHE);
+ const response = await cache.match(CLIENT_BUILDS_KEY);
+ const data = response ? await response.json() : null;
+ return data && typeof data === "object" ? data : {};
+ } catch (_) {
+ return {};
+ }
+}
+
+async function writeClientBuilds(map) {
+ try {
+ const cache = await caches.open(SW_STATE_CACHE);
+ await cache.put(CLIENT_BUILDS_KEY, new Response(JSON.stringify(map), { headers: { "Content-Type": "application/json" } }));
+ } catch (_) {
+ // Without the Cache API nothing was cached either.
+ }
+}
+
+/** Notes which build an open tab runs, and forgets tabs that are gone. */
+async function recordClientBuild(clientId, buildId) {
+ if (!clientId || typeof buildId !== "string" || !buildId) return;
+ const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ const openIds = new Set(open.map((c) => c.id));
+ const map = await readClientBuilds();
+ const next = {};
+ for (const [id, build] of Object.entries(map)) if (openIds.has(id)) next[id] = build;
+ next[clientId] = buildId;
+ await writeClientBuilds(next);
+}
+
+/**
+ * Deletes the shell caches of builds no open tab runs any more. This
+ * worker's own build is kept, and so is every build a tab still runs: such a
+ * tab may still load a chunk of its build after a deploy, and the new build
+ * no longer has it.
+ */
+async function deleteStaleShellCaches() {
+ if (typeof caches === "undefined" || typeof caches.keys !== "function") return;
+ try {
+ const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ const openIds = new Set(open.map((c) => c.id));
+ const map = await readClientBuilds();
+ const inUse = new Set([BUILD_ID]);
+ for (const [id, build] of Object.entries(map)) if (openIds.has(id)) inUse.add(build);
+ const keys = await caches.keys();
+ await Promise.all(keys
+ .filter((key) => key.startsWith(SHELL_CACHE_PREFIX) && !inUse.has(key.slice(SHELL_CACHE_PREFIX.length)))
+ .map((key) => caches.delete(key)));
+ } catch (_) {
+ // Tried again at the next activation.
+ }
+}
+
self.addEventListener("fetch", () => {});
self.addEventListener("push", (event) => {
@@ -50,6 +121,16 @@ self.addEventListener("pushsubscriptionchange", (event) => {
self.addEventListener("message", (event) => {
const data = event.data || {};
+ if (data.type === "client-build") {
+ event.waitUntil(recordClientBuild(event.source && event.source.id, data.buildId).then(deleteStaleShellCaches));
+ return;
+ }
+ if (data.type === "skip-waiting") {
+ // The tab's "Reload" for a new build: take over now instead of waiting
+ // for every tab of the old build to close.
+ self.skipWaiting();
+ return;
+ }
if (data.type === "mailto-client-ready") {
if (event.source && event.source.id) {
MAILTO_CLIENTS.set(event.source.id, {