feat: version the service worker by build and offer a reload after a deploy

This commit is contained in:
Linus Rath
2026-11-01 11:13:31 +01:00
parent a480ddb6d1
commit 91be465cbc
42 changed files with 679 additions and 10 deletions
+2
View File
@@ -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({
<PluginAppEvents />
<PluginPageNavigator />
<PWAInstallPrompt />
<AppUpdatePrompt />
<DevicePolicyHost />
<PushNotificationPrompt />
{/* Lite is a static bundle: no server keeps an archive there. */}
+16
View File
@@ -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' } },
);
}
@@ -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<string | null>>(),
reloadToNewBuild: vi.fn(async () => {}),
}));
vi.mock("@/lib/app-update", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/app-update")>()),
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(<AppUpdatePrompt />);
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(<AppUpdatePrompt />);
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(<AppUpdatePrompt />);
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);
});
});
+69
View File
@@ -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;
}
+9 -3
View File
@@ -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;
+76
View File
@@ -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<typeof import('@/lib/lite')>();
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);
});
});
+48
View File
@@ -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);
});
});
+50
View File
@@ -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<string, Map<string, Response>>();
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<string, (event: unknown) => 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<string, unknown> = {}) {
const waits: Promise<unknown>[] = [];
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
await Promise.all(waits);
}
return { dispatch, listeners, skipped: () => skipped };
}
+103
View File
@@ -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=<build id>, 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<string | null> {
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<string | null> {
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<void> {
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<void>((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/');
}
+2 -4
View File
@@ -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<ServiceWorkerRegistration> {
// 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;
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "عدم التذكير مرة أخرى",
"dismiss_aria": "تجاهل مطالبة التثبيت"
},
"app_update": {
"ready": "إصدار جديد من {appName} جاهز.",
"reload": "إعادة التحميل"
},
"deep_link": {
"message_not_found": "لم تعد هذه الرسالة متاحة.",
"thread_not_found": "لم تعد هذه المحادثة متاحة.",
+4
View File
@@ -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.",
+4
View File
@@ -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á.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "دیگر یادآوری نکن",
"dismiss_aria": "رد کردن پیشنهاد نصب"
},
"app_update": {
"ready": "نسخهٔ تازه‌ای از {appName} آماده است.",
"reload": "بارگیری دوباره"
},
"deep_link": {
"message_not_found": "این پیام دیگر در دسترس نیست.",
"thread_not_found": "این گفتگو دیگر در دسترس نیست.",
+4
View File
@@ -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.",
+4
View File
@@ -5634,6 +5634,10 @@
"dont_remind": "אל תזכיר לי שוב",
"dismiss_aria": "בטל הודעת התקנה"
},
"app_update": {
"ready": "גרסה חדשה של {appName} מוכנה.",
"reload": "טעינה מחדש"
},
"deep_link": {
"message_not_found": "הודעה זו אינה זמינה עוד.",
"thread_not_found": "שיחה זו אינה זמינה עוד.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "今後表示しない",
"dismiss_aria": "インストールプロンプトを閉じる"
},
"app_update": {
"ready": "{appName} の新しいバージョンを利用できます。",
"reload": "再読み込み"
},
"deep_link": {
"message_not_found": "このメッセージは利用できなくなりました。",
"thread_not_found": "この会話は利用できなくなりました。",
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "다시 알리지 않음",
"dismiss_aria": "설치 프롬프트 닫기"
},
"app_update": {
"ready": "{appName}의 새 버전이 준비되었습니다.",
"reload": "새로고침"
},
"deep_link": {
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
"thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.",
+4
View File
@@ -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.",
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "Дахиж сануулахгүй",
"dismiss_aria": "Суулгах сануулгыг хаах"
},
"app_update": {
"ready": "{appName}-ийн шинэ хувилбар бэлэн боллоо.",
"reload": "Дахин ачаалах"
},
"deep_link": {
"message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.",
"thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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ă.",
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "Больше не напоминать",
"dismiss_aria": "Закрыть запрос на установку"
},
"app_update": {
"ready": "Готова новая версия {appName}.",
"reload": "Перезагрузить"
},
"deep_link": {
"message_not_found": "Это письмо больше недоступно.",
"thread_not_found": "Эта переписка больше недоступна.",
+4
View File
@@ -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á.",
+4
View File
@@ -2017,6 +2017,10 @@
"dont_remind": "ไม่ต้องแจ้งเตือนอีก",
"dismiss_aria": "ปิดการแจ้งเตือนการติดตั้ง"
},
"app_update": {
"ready": "{appName} เวอร์ชันใหม่พร้อมแล้ว",
"reload": "โหลดใหม่"
},
"plugin_pages": {
"loading": "กำลังโหลดหน้าเว็บ...",
"not_available_title": "หน้านี้ไม่พร้อมใช้งาน",
+4
View File
@@ -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.",
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "Більше не нагадувати",
"dismiss_aria": "Закрити запит на встановлення"
},
"app_update": {
"ready": "Нова версія {appName} готова.",
"reload": "Перезавантажити"
},
"deep_link": {
"message_not_found": "Цей лист більше недоступний.",
"thread_not_found": "Ця розмова більше недоступна.",
+4
View File
@@ -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.",
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "不再提醒",
"dismiss_aria": "關閉安裝提示"
},
"app_update": {
"ready": "{appName} 新版本已準備就緒。",
"reload": "重新載入"
},
"deep_link": {
"message_not_found": "已無法使用此郵件。",
"thread_not_found": "已無法使用此對話串。",
+4
View File
@@ -5633,6 +5633,10 @@
"dont_remind": "不再提醒",
"dismiss_aria": "关闭安装提示"
},
"app_update": {
"ready": "{appName} 新版本已就绪。",
"reload": "重新加载"
},
"deep_link": {
"message_not_found": "此邮件已不可用。",
"thread_not_found": "此会话已不可用。",
+17
View File
@@ -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,
+84 -3
View File
@@ -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=<build id>, so every deploy
// installs a new worker that knows its build. Anything it caches goes into
// caches named after that build (bulwark-shell-<build id>), 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, {