import * as api from '$lib/apiServer.js'; // In a few places in this module, I've used suffix to identify which type we're working with: // * ...Base64 is a string containing a URL-safe base64 value; // * ...Buffer is an ArrayBuffer holding untyped bytes; and // * ...Bytes is a Uint8Array holding typed bytes. // Working with byte streams in JS is _fun_. export class Push { static async boot(events) { const serviceWorker = await navigator.serviceWorker.ready; const pushManager = serviceWorker.pushManager; const subscription = await serviceWorker.pushManager.getSubscription(); const push = new Push(pushManager, subscription); for (const event of events) { push.onEvent(event); } return push; } vapidKey = $state(null); subscription = $state(null); constructor(pushManager, subscription) { this.pushManager = pushManager; this.subscription = subscription; } async hasPermission() { const vapidKeyBuffer = this.vapidKey; const pushOptions = { userVisibleOnly: true, applicationServerKey: vapidKeyBuffer, }; const state = await this.pushManager.permissionState(pushOptions); return state === 'granted'; } async subscribe() { const vapidKeyBuffer = this.vapidKey; const pushOptions = { userVisibleOnly: true, applicationServerKey: vapidKeyBuffer, }; this.subscription = await this.pushManager.subscribe(pushOptions); const subscriptionJSON = this.subscription.toJSON(); const vapidKeyBytes = new Uint8Array(vapidKeyBuffer); const vapidKeyBase64 = vapidKeyBytes.toBase64({ alphabet: 'base64url' }); await api.createPushSubscription(subscriptionJSON, vapidKeyBase64); } async resubscribe() { if (this.subscription !== null) { // If we have a subscription but it's not for the current VAPID key, then the VAPID key has // been rotated and we need to replace the subscription. The server cannot deliver messages to // subscriptions associated with the old VAPID key after rotation. // Per spec, the `options.applicationServerKey` field is either null or an ArrayBuffer, // regardless of the representation passed into `subscribe` to create the subscription: // // if (!equalArrayBuffers(this.subscription.options.applicationServerKey, this.vapidKey)) { // We have a subscription, and the server key has rotated, so resubscribe. Destroy the old // subscription first - some UAs (Firefox) want subscriptions within an origin to share a // consistent VAPID key, and we're explicitly changing the VAPID key here. await this.unsubscribe(); await this.subscribe(); } } else if (await this.hasPermission()) { // If we have permission to create push subscriptions, but no push subscription, then set up // a new subscription. This primarily happens after logging into Pilcrow if the user // previously had a push subscription and has logged out. await this.subscribe(); } } async unsubscribe() { if (this.subscription !== null) { await this.subscription.unsubscribe(); this.subscription = null; } } onEvent(event) { switch (event.type) { case 'vapid': return this.onVapidEvent(event); } } onVapidEvent(event) { switch (event.event) { case 'changed': return this.onVapidChanged(event); } } onVapidChanged(event) { const { key: vapidKeyBase64 } = event; // For ease of use later on, parse the key into an ArrayBuffer. This is a little fiddly because // of the APIs involved, but it makes comparing the key to the current subscription's key // (which is also provided as an ArrayBuffer) much easier. const vapidKeyBytes = Uint8Array.fromBase64(vapidKeyBase64, { alphabet: 'base64url', }); // In practice, `vapidKeyBytes.buffer` is going to be the same bytes as this slice, but in // principle it could be a subset of the underlying buffer, and there's very little downside to // being meticulous here. this.vapidKey = vapidKeyBytes.buffer.slice( vapidKeyBytes.byteOffset, vapidKeyBytes.byteOffset + vapidKeyBytes.byteLength, ); // Note that `resubscribe()` is async; this will start the [re]subscription process but will // return to the caller before it completes. I'm not willing to make the entire event handling // chain all the way back up to the EventSource asynchronous, since EventSource isn't designed // to support that (we could make it work), and we can't wait for async functions in a non-async // context, so this is the best we can do. this.resubscribe(); } } function equalArrayBuffers(aBuffer, bBuffer) { // You might be thinking, surely there's a way to compare two array buffers for equality. I // certainly expected that there would be. [Nope]. // // [Nope]: https://github.com/wbinnssmith/arraybuffer-equal // // The algorithm here is simple enough not to need an external dependency. However, this // comparison is not designed to deal with detached ArrayBuffer instances. const aBytes = new Uint8Array(aBuffer); const bBytes = new Uint8Array(bBuffer); return aBytes.length === bBytes.length && aBytes.every((value, index) => value === bBytes[index]); }