| 1 | // Service-worker half of aviso, a classic script the app's own sw.js |
| 2 | // loads with importScripts. It owns nothing about the worker's |
| 3 | // lifecycle — no skipWaiting, no clients.claim, no listeners of its |
| 4 | // own; the app attaches these handlers inside its listeners and passes |
| 5 | // the result to event.waitUntil. |
| 6 | (function (root) { |
| 7 | "use strict"; |
| 8 | |
| 9 | function toBytes(base64url) { |
| 10 | const pad = "=".repeat((4 - (base64url.length % 4)) % 4); |
| 11 | const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/"); |
| 12 | const raw = atob(b64); |
| 13 | const out = new Uint8Array(raw.length); |
| 14 | for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); |
| 15 | return out; |
| 16 | } |
| 17 | |
| 18 | function toBase64url(buf) { |
| 19 | let s = ""; |
| 20 | const bytes = new Uint8Array(buf); |
| 21 | for (const b of bytes) s += String.fromCharCode(b); |
| 22 | return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 23 | } |
| 24 | |
| 25 | // validateURL admits only a root-relative path on the app's own |
| 26 | // origin: no scheme, no protocol-relative "//", no backslashes, and |
| 27 | // the resolved URL must still be on origin. Applied to the default |
| 28 | // decoder, custom decoder output and fallbackURL alike — a click |
| 29 | // must never navigate off the app, and an absolute URL is refused |
| 30 | // even on-origin so the rule has no exceptions to reason about. |
| 31 | function validateURL(raw, origin) { |
| 32 | if (typeof raw !== "string" || raw === "") return null; |
| 33 | if (raw[0] !== "/" || raw[1] === "/" || raw.indexOf("\\") !== -1) return null; |
| 34 | let u; |
| 35 | try { u = new URL(raw, origin); } catch (e) { return null; } |
| 36 | if (u.origin !== origin) return null; |
| 37 | return u.href; |
| 38 | } |
| 39 | |
| 40 | // storedURL re-validates what a previous handlePush put in |
| 41 | // notification.data — validateURL's absolute form — by reducing it |
| 42 | // to its path and running the same rule. Anything else is refused. |
| 43 | function storedURL(raw, origin) { |
| 44 | if (typeof raw !== "string" || raw.indexOf(origin + "/") !== 0) return null; |
| 45 | return validateURL(raw.slice(origin.length), origin); |
| 46 | } |
| 47 | |
| 48 | // decodeDefault reads {title, body, url, tag}; title is required. |
| 49 | function decodeDefault(event, origin) { |
| 50 | if (!event.data) return null; |
| 51 | let p; |
| 52 | try { p = event.data.json(); } catch (e) { return null; } |
| 53 | if (!p || typeof p.title !== "string" || p.title === "") return null; |
| 54 | const options = { data: {} }; |
| 55 | if (typeof p.body === "string") options.body = p.body; |
| 56 | if (typeof p.tag === "string") options.tag = p.tag; |
| 57 | // Kept root-relative here; show() is the one place that validates |
| 58 | // and resolves, for this decoder and custom ones alike. |
| 59 | if (typeof p.url === "string") options.data.url = p.url; |
| 60 | return { title: p.title, options }; |
| 61 | } |
| 62 | |
| 63 | // handlePush always ends in a visible notification: WebKit revokes |
| 64 | // push for a worker that receives without showing, so a payload the |
| 65 | // decoder cannot read shows the app's fallback rather than nothing. |
| 66 | async function handlePush(event, opts) { |
| 67 | if (!opts || typeof opts.fallback !== "function") { |
| 68 | throw new Error("aviso: fallback is required"); |
| 69 | } |
| 70 | const origin = root.location.origin; |
| 71 | let n = null; |
| 72 | try { |
| 73 | n = opts.decode ? await opts.decode(event) : decodeDefault(event, origin); |
| 74 | } catch (e) { n = null; } |
| 75 | if (usable(n)) { |
| 76 | try { |
| 77 | return await show(n, origin); |
| 78 | } catch (e) { |
| 79 | // A decoder can hand back options the platform refuses — |
| 80 | // uncloneable data, an invalid combination — and that must |
| 81 | // still end in a notification. |
| 82 | } |
| 83 | } |
| 84 | return show(await opts.fallback(event), origin); |
| 85 | } |
| 86 | |
| 87 | function underKey(sub, key) { |
| 88 | const k = sub.options && sub.options.applicationServerKey; |
| 89 | return !!k && toBase64url(k) === key; |
| 90 | } |
| 91 | |
| 92 | function usable(n) { |
| 93 | return !!n && typeof n.title === "string" && n.title !== ""; |
| 94 | } |
| 95 | |
| 96 | function show(n, origin) { |
| 97 | const options = Object.assign({}, n.options || {}); |
| 98 | options.data = Object.assign({}, options.data || {}); |
| 99 | if (options.data.url !== undefined) { |
| 100 | const href = validateURL(options.data.url, origin); |
| 101 | if (href) options.data.url = href; else delete options.data.url; |
| 102 | } |
| 103 | return root.registration.showNotification(n.title, options); |
| 104 | } |
| 105 | |
| 106 | // handleClick closes the notification, focuses a window already at |
| 107 | // the destination, else opens it, else the fallback. |
| 108 | async function handleClick(event, opts) { |
| 109 | const origin = root.location.origin; |
| 110 | event.notification.close(); |
| 111 | const data = event.notification.data || {}; |
| 112 | let href = data.url ? storedURL(data.url, origin) : null; |
| 113 | if (!href && opts && opts.fallbackURL) href = validateURL(opts.fallbackURL, origin); |
| 114 | if (!href) return; |
| 115 | const all = await root.clients.matchAll({ type: "window", includeUncontrolled: true }); |
| 116 | for (const c of all) { |
| 117 | if (c.url === href && "focus" in c) return c.focus(); |
| 118 | } |
| 119 | return root.clients.openWindow(href); |
| 120 | } |
| 121 | |
| 122 | // handleSubscriptionChange renews and saves. A worker forgets its |
| 123 | // variables when it is terminated, so the key is fetched on demand |
| 124 | // through `publicKey()`; the helper subscribes and builds the full |
| 125 | // Subscribe body itself, and the app's `save(body)` only posts. |
| 126 | // Saving can fail — an installed app with no live session — and then |
| 127 | // it fails silently: reconcile() repairs on the next page open after |
| 128 | // sign-in. Nothing here retries and nothing here prompts. |
| 129 | async function handleSubscriptionChange(event, opts) { |
| 130 | if (!opts || typeof opts.publicKey !== "function" || typeof opts.save !== "function") { |
| 131 | throw new Error("aviso: publicKey() and save() are required"); |
| 132 | } |
| 133 | let key; |
| 134 | try { key = await opts.publicKey(); } catch (e) { return false; } |
| 135 | if (typeof key !== "string" || key === "") return false; |
| 136 | // The browser's replacement subscription reuses the old options, |
| 137 | // so after a server key rotation it is signed for a key the server |
| 138 | // no longer holds: saved under the fetched key it would look valid |
| 139 | // and never receive anything. Replace it rather than label it. |
| 140 | let sub = event.newSubscription || null; |
| 141 | if (sub && !underKey(sub, key)) { |
| 142 | try { await sub.unsubscribe(); } catch (e) { /* replaced below either way */ } |
| 143 | sub = null; |
| 144 | } |
| 145 | if (!sub) { |
| 146 | try { |
| 147 | sub = await root.registration.pushManager.subscribe({ |
| 148 | userVisibleOnly: true, |
| 149 | applicationServerKey: toBytes(key), |
| 150 | }); |
| 151 | } catch (e) { sub = null; } |
| 152 | } |
| 153 | if (!sub) return false; |
| 154 | const json = sub.toJSON(); |
| 155 | const body = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey: key }; |
| 156 | const old = event.oldSubscription; |
| 157 | if (old && old.endpoint && old.endpoint !== json.endpoint) body.previousEndpoint = old.endpoint; |
| 158 | try { |
| 159 | const resp = await opts.save(body); |
| 160 | return !(resp && typeof resp === "object" && resp.ok === false); |
| 161 | } catch (e) { |
| 162 | return false; |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | root.AvisoSW = { handlePush, handleClick, handleSubscriptionChange, validateURL }; |
| 167 | })(typeof self !== "undefined" ? self : globalThis); |
| 168 | |