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, {