Session 27: PWA autopilot — NetworkFirst cache policy, stale bucket cleanup, offline fallback, push helper, manifest polish, tier fix (1584 tests)

This commit is contained in:
Kev
2026-06-13 10:57:49 -04:00
parent f8a51cd9d0
commit 66fafd8429
10 changed files with 445 additions and 16 deletions
+83
View File
@@ -0,0 +1,83 @@
/**
* Push notification subscription (Session 27).
*
* Client-side helpers to request permission and (un)subscribe to Web Push
* via the active service worker. The SW already handles incoming `push`
* and `notificationclick` events (see web/src/sw.ts); these helpers manage
* the browser→push-service subscription handshake.
*
* VAPID: subscription requires NEXT_PUBLIC_VAPID_PUBLIC_KEY. Until that
* key is generated and set (a future session), subscribeToPush returns
* null rather than throwing — callers degrade gracefully.
*
* The resulting PushSubscription is meant to be POSTed to the backend and
* stored (Supabase) so the notification trigger system can target it.
*/
/** Browser support check — both SW and PushManager must exist. */
export function isPushSupported(): boolean {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
/**
* VAPID public keys are base64url; the subscribe API wants a BufferSource.
* We return the backing ArrayBuffer so the type is unambiguously
* ArrayBuffer (not ArrayBufferLike) for `applicationServerKey`.
*/
function vapidKeyToBuffer(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = window.atob(base64);
const buffer = new ArrayBuffer(raw.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < raw.length; i += 1) view[i] = raw.charCodeAt(i);
return buffer;
}
/**
* Request permission and subscribe. Returns the PushSubscription on
* success, or null when unsupported / denied / VAPID key missing.
*/
export async function subscribeToPush(): Promise<PushSubscription | null> {
if (!isPushSupported()) return null;
const vapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidKey) {
console.warn('[push] NEXT_PUBLIC_VAPID_PUBLIC_KEY not set — cannot subscribe yet.');
return null;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') return null;
const registration = await navigator.serviceWorker.ready;
// Reuse an existing subscription if the browser already has one.
const existing = await registration.pushManager.getSubscription();
if (existing) return existing;
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidKeyToBuffer(vapidKey),
});
}
/** Unsubscribe the active push subscription, if any. Idempotent. */
export async function unsubscribeFromPush(): Promise<boolean> {
if (!isPushSupported()) return true;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return true;
return subscription.unsubscribe();
}
/** Current permission state without prompting — for UI affordances. */
export function pushPermission(): NotificationPermission | 'unsupported' {
if (!isPushSupported()) return 'unsupported';
return Notification.permission;
}