fix: restore push when the browser replaces its subscription with no tab open

This commit is contained in:
Linus Rath
2026-11-04 03:13:56 +01:00
parent 8a69c35206
commit d520af51b2
5 changed files with 197 additions and 14 deletions
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { forgetRelayRegistration, keepRelayRegistration, relayRegistrationsKey } from '@/lib/pwa-notify/worker-state';
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal('caches', {
open: async () => ({
match: async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined),
put: async (key: string, res: Response) => { store.set(key, await res.text()); },
}),
});
});
const stored = () => JSON.parse(store.get(relayRegistrationsKey(window.location.origin)) ?? 'null');
describe('what the worker keeps to re-register without a tab', () => {
it('keeps one entry per account and the key', async () => {
await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, 'KEY');
await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY');
expect(stored()).toEqual({
applicationServerKey: 'KEY',
entries: { a: { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, b: { relay: 'https://r', subscriptionId: 'd2' } },
});
});
it('forgets an account, and leaves the others', async () => {
await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY');
await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY');
await forgetRelayRegistration('a');
expect(Object.keys(stored().entries)).toEqual(['b']);
});
it('stays out of the way without a Cache API', async () => {
vi.stubGlobal('caches', undefined);
await expect(keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY')).resolves.toBeUndefined();
await expect(forgetRelayRegistration('a')).resolves.toBeUndefined();
});
});
@@ -7,9 +7,11 @@ import { describe, expect, it, vi } from 'vitest';
// in a VM with a minimal ServiceWorkerGlobalScope and fire the event.
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown) {
const listeners = new Map<string, (event: unknown) => void>();
const subscribe = vi.fn(async () => ({}));
const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) };
const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription);
const relayCalls: Array<{ url: string; body: unknown }> = [];
const self = {
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
@@ -18,7 +20,14 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
registration: { pushManager: { subscribe } },
};
const context = vm.createContext({
self, caches: {}, fetch: vi.fn(), URL, URLSearchParams, Response, Date, console,
self,
atob,
caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) },
fetch: vi.fn(async (url: string, init: { body: string }) => {
relayCalls.push({ url, body: JSON.parse(init.body) });
return { ok: true };
}),
URL, URLSearchParams, Response, Date, console, Uint8Array,
});
vm.runInContext(SW_SOURCE, context);
@@ -27,13 +36,45 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
await Promise.all(waits);
}
return { fire, subscribe };
return { fire, subscribe, relayCalls, newSubscription };
}
// The push service dropped or replaced the subscription while no page was
// involved (#1321). The worker puts a subscription back and tells the open
// tabs, which hold the login needed to register the new endpoint.
describe('service worker pushsubscriptionchange', () => {
const REGISTRATIONS = {
applicationServerKey: 'QUJD',
entries: {
a: { relay: 'https://relay.example', subscriptionId: 'device-a', accountLabel: 'a@example.org' },
b: { relay: 'https://relay.example', subscriptionId: 'device-b', accountLabel: 'b@example.org' },
},
};
it('tells the relay the new endpoint itself, for every account, with no tab open', async () => {
const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS);
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
expect(relayCalls).toEqual([
{ url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-a', subscription: newSubscription.toJSON(), accountLabel: 'a@example.org' } },
{ url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-b', subscription: newSubscription.toJSON(), accountLabel: 'b@example.org' } },
]);
});
it('subscribes with the kept key when the browser gives no old subscription', async () => {
const { fire, subscribe, relayCalls } = loadWorker([], REGISTRATIONS);
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription: null });
expect(subscribe).toHaveBeenCalledTimes(1);
expect(subscribe.mock.calls[0][0]).toMatchObject({ userVisibleOnly: true });
expect(Array.from(subscribe.mock.calls[0][0].applicationServerKey!)).toEqual([65, 66, 67]);
expect(relayCalls).toHaveLength(2);
});
it('calls no relay for a browser where push was never on', async () => {
const { fire, relayCalls, newSubscription } = loadWorker([]);
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
expect(relayCalls).toEqual([]);
});
it('re-subscribes with the old key and tells the open tabs to re-register', async () => {
const tab = { postMessage: vi.fn() };
const { fire, subscribe } = loadWorker([tab]);
+54
View File
@@ -29,6 +29,60 @@ export function pendingReplyKey(origin: string): string {
return `${origin}${BASE_PATH}/__push-state/pending-reply`;
}
/** The cache key sw.js finds the relay registrations under. */
export function relayRegistrationsKey(origin: string): string {
return `${origin}${BASE_PATH}/__push-state/relay-registrations`;
}
export interface RelayRegistration {
relay: string;
subscriptionId: string;
accountLabel?: string;
}
interface RelayRegistrations {
applicationServerKey?: string;
entries: Record<string, RelayRegistration>;
}
async function readRelayRegistrations(cache: Cache, key: string): Promise<RelayRegistrations> {
const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined;
return stored?.entries ? stored : { entries: {} };
}
/**
* Lets the worker re-register a new browser endpoint with the relay when no tab
* is open (pushsubscriptionchange). None of this is secret to the browser that
* subscribed, and it opens nothing but the relay record.
*/
export async function keepRelayRegistration(accountId: string, entry: RelayRegistration, applicationServerKey: string): Promise<void> {
try {
if (typeof caches === 'undefined') return;
const cache = await caches.open(PUSH_STATE_CACHE);
const key = relayRegistrationsKey(window.location.origin);
const stored = await readRelayRegistrations(cache, key);
stored.entries[accountId] = entry;
stored.applicationServerKey = applicationServerKey;
await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } }));
} catch {
// A new endpoint then waits for the page's re-sync.
}
}
export async function forgetRelayRegistration(accountId: string): Promise<void> {
try {
if (typeof caches === 'undefined') return;
const cache = await caches.open(PUSH_STATE_CACHE);
const key = relayRegistrationsKey(window.location.origin);
const stored = await readRelayRegistrations(cache, key);
if (!(accountId in stored.entries)) return;
delete stored.entries[accountId];
await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } }));
} catch {
// Signing out deletes the store anyway.
}
}
export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise<void> {
try {
if (typeof caches === 'undefined') return;
+7
View File
@@ -13,6 +13,7 @@ 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 { forgetRelayRegistration, keepRelayRegistration } from '@/lib/pwa-notify/worker-state';
import {
EMPTY_NOTIFICATION_MUTES,
isAccountMuted,
@@ -801,6 +802,11 @@ async function enableWebPushForThisTab(
},
accountLabel: params.accountLabel,
});
await keepRelayRegistration(accountId, {
relay: relayBaseUrl,
subscriptionId: deviceClientId,
...(params.accountLabel ? { accountLabel: params.accountLabel } : {}),
}, vapidPublicKey);
// Reuse the JMAP-side PushSubscription if the server still has it, just
// refreshing the expiry so it doesn't time out between sessions. With
@@ -983,6 +989,7 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise<v
}
localStorage.removeItem(subIdKey);
localStorage.removeItem(relayBaseKey(accountId));
await forgetRelayRegistration(accountId);
// Enabling again starts from the full emailPush map.
localStorage.removeItem(emailPushRefusedKey(accountId));
+51 -10
View File
@@ -975,20 +975,61 @@ async function writeNotifiedIds(accountId, ids) {
}
}
/** What the page left here: lib/pwa-notify/worker-state.ts. */
async function readRelayRegistrations() {
try {
const cache = await caches.open(PUSH_STATE_CACHE);
const res = await cache.match(`${self.location.origin}${BASE_PATH}/__push-state/relay-registrations`);
const stored = res ? await res.json() : null;
return stored && stored.entries && typeof stored.entries === "object" ? stored : null;
} catch (_) {
return null;
}
}
function applicationServerKeyOf(base64Url) {
const padded = base64Url + "=".repeat((4 - (base64Url.length % 4)) % 4);
const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from(raw, (character) => character.charCodeAt(0));
}
// The push service dropped or replaced the browser's subscription (expiry,
// a key rotation, revoked permission). Re-subscribe with the same relay key
// unless the browser already did, then tell the open tabs: only a page has
// the login needed to register the new endpoint with the relay and the mail
// server (resyncWebPush in lib/web-push.ts). With no tab open, the next page
// load's re-sync picks the new subscription up.
// unless the browser already did. The mail server's subscription points at the
// relay record, not at the endpoint, so telling the relay is all it takes, and
// that needs no login: push is back with no tab open. Open tabs hear of it too,
// for what only a page can do (resyncWebPush in lib/web-push.ts).
async function handlePushSubscriptionChange(event) {
const old = event.oldSubscription;
if (!event.newSubscription && old && old.options) {
try {
await self.registration.pushManager.subscribe(old.options);
} catch (_) {
// Permission revoked or push service unreachable: the page's re-sync
// subscribes again itself when it can.
const registrations = await readRelayRegistrations();
let subscription = event.newSubscription || null;
if (!subscription) {
const options = old && old.options
? old.options
: registrations && registrations.applicationServerKey
? { userVisibleOnly: true, applicationServerKey: applicationServerKeyOf(registrations.applicationServerKey) }
: null;
if (options) {
try {
subscription = await self.registration.pushManager.subscribe(options);
} catch (_) {
// Permission revoked or push service unreachable: the page's re-sync
// subscribes again itself when it can.
}
}
}
if (subscription && registrations && typeof subscription.toJSON === "function") {
const json = subscription.toJSON();
for (const entry of Object.values(registrations.entries)) {
try {
await fetch(`${entry.relay}/api/push/register/web`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscriptionId: entry.subscriptionId, subscription: json, accountLabel: entry.accountLabel }),
});
} catch (_) {
// The page's re-sync registers it when it next runs.
}
}
}
const allClients = await self.clients.matchAll({