mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat: version the service worker by build and offer a reload after a deploy
This commit is contained in:
@@ -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. */}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "عدم التذكير مرة أخرى",
|
||||
"dismiss_aria": "تجاهل مطالبة التثبيت"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "إصدار جديد من {appName} جاهز.",
|
||||
"reload": "إعادة التحميل"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "لم تعد هذه الرسالة متاحة.",
|
||||
"thread_not_found": "لم تعد هذه المحادثة متاحة.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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á.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "دیگر یادآوری نکن",
|
||||
"dismiss_aria": "رد کردن پیشنهاد نصب"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "نسخهٔ تازهای از {appName} آماده است.",
|
||||
"reload": "بارگیری دوباره"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "این پیام دیگر در دسترس نیست.",
|
||||
"thread_not_found": "این گفتگو دیگر در دسترس نیست.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5634,6 +5634,10 @@
|
||||
"dont_remind": "אל תזכיר לי שוב",
|
||||
"dismiss_aria": "בטל הודעת התקנה"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "גרסה חדשה של {appName} מוכנה.",
|
||||
"reload": "טעינה מחדש"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "הודעה זו אינה זמינה עוד.",
|
||||
"thread_not_found": "שיחה זו אינה זמינה עוד.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "今後表示しない",
|
||||
"dismiss_aria": "インストールプロンプトを閉じる"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "{appName} の新しいバージョンを利用できます。",
|
||||
"reload": "再読み込み"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "このメッセージは利用できなくなりました。",
|
||||
"thread_not_found": "この会話は利用できなくなりました。",
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "다시 알리지 않음",
|
||||
"dismiss_aria": "설치 프롬프트 닫기"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "{appName}의 새 버전이 준비되었습니다.",
|
||||
"reload": "새로고침"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
|
||||
"thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "Дахиж сануулахгүй",
|
||||
"dismiss_aria": "Суулгах сануулгыг хаах"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "{appName}-ийн шинэ хувилбар бэлэн боллоо.",
|
||||
"reload": "Дахин ачаалах"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.",
|
||||
"thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ă.",
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "Больше не напоминать",
|
||||
"dismiss_aria": "Закрыть запрос на установку"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "Готова новая версия {appName}.",
|
||||
"reload": "Перезагрузить"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Это письмо больше недоступно.",
|
||||
"thread_not_found": "Эта переписка больше недоступна.",
|
||||
|
||||
@@ -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á.",
|
||||
|
||||
@@ -2017,6 +2017,10 @@
|
||||
"dont_remind": "ไม่ต้องแจ้งเตือนอีก",
|
||||
"dismiss_aria": "ปิดการแจ้งเตือนการติดตั้ง"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "{appName} เวอร์ชันใหม่พร้อมแล้ว",
|
||||
"reload": "โหลดใหม่"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "กำลังโหลดหน้าเว็บ...",
|
||||
"not_available_title": "หน้านี้ไม่พร้อมใช้งาน",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "Більше не нагадувати",
|
||||
"dismiss_aria": "Закрити запит на встановлення"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "Нова версія {appName} готова.",
|
||||
"reload": "Перезавантажити"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Цей лист більше недоступний.",
|
||||
"thread_not_found": "Ця розмова більше недоступна.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "不再提醒",
|
||||
"dismiss_aria": "關閉安裝提示"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "{appName} 新版本已準備就緒。",
|
||||
"reload": "重新載入"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "已無法使用此郵件。",
|
||||
"thread_not_found": "已無法使用此對話串。",
|
||||
|
||||
@@ -5633,6 +5633,10 @@
|
||||
"dont_remind": "不再提醒",
|
||||
"dismiss_aria": "关闭安装提示"
|
||||
},
|
||||
"app_update": {
|
||||
"ready": "{appName} 新版本已就绪。",
|
||||
"reload": "重新加载"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "此邮件已不可用。",
|
||||
"thread_not_found": "此会话已不可用。",
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user