pub const CF_JS: &str = "/* cratefield-ui embed (ADR 0010, issue #73). Served at /ui/cf.js.\n *\n * <script type=\"module\" src=\"https://api.example.com/ui/cf.js\"></script>\n * <cf-form module=\"waitlist\" action=\"join\" product=\"kontinuum\"></cf-form>\n * <cf-status module=\"waitlist\"></cf-status>\n *\n * An embed, not a renderer: every byte of markup comes from the harness.\n * <cf-form> fetches /ui/<module>/<action>?fragment=1 from the API origin\n * (the script\'s own origin unless `base` says otherwise), inserts it into\n * the light DOM, and turns the submit into a fetch of the same /ui route,\n * swapping the returned fragment (notice, or the form with its errors)\n * back in. Attributes that name a field pre-fill and hide it (sent as\n * `?field=value&hide=field`). When a\n * fragment carries a Turnstile widget, Turnstile\'s script is loaded once\n * and the widget rendered. <cf-status> is a <cf-form> whose action is a\n * GET, with `token` taken from the page URL. No dependencies, no shadow\n * DOM, no inline styles; style the cf-* classes.\n *\n * Events on the element: cf:loaded, cf:submitted (detail.status), cf:error.\n *\n * Browser push (issue #183) is the second half: `cf.push.subscribe()` and\n * <cf-push>. See the block below the elements.\n */\nconst ORIGIN = new URL(import.meta.url).origin;\nconst SKIP = new Set([\"module\", \"action\", \"base\", \"class\", \"id\", \"style\", \"hidden\", \"slot\"]);\nconst TURNSTILE = \"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit\";\nlet turnstileLoading = null;\n\nfunction loadTurnstile() {\n if (window.turnstile) return Promise.resolve(window.turnstile);\n if (!turnstileLoading) {\n turnstileLoading = new Promise((resolve, reject) => {\n const s = document.createElement(\"script\");\n s.src = TURNSTILE;\n s.async = true;\n s.onload = () => resolve(window.turnstile);\n s.onerror = () => reject(new Error(\"turnstile failed to load\"));\n document.head.append(s);\n });\n }\n return turnstileLoading;\n}\n\nclass CfForm extends HTMLElement {\n static get observedAttributes() {\n return [\"module\", \"action\"];\n }\n\n get action() {\n return this.getAttribute(\"action\") || \"join\";\n }\n\n /** The /ui URL for this element, with field attributes as the query. */\n url(fragment) {\n const base = this.getAttribute(\"base\") || ORIGIN;\n const u = new URL(`/ui/${this.getAttribute(\"module\")}/${this.action}`, base);\n const hide = [];\n for (const { name, value } of this.attributes) {\n if (SKIP.has(name)) continue;\n u.searchParams.set(name, value);\n hide.push(name);\n }\n for (const [k, v] of this.extraParams()) u.searchParams.set(k, v);\n if (hide.length) u.searchParams.set(\"hide\", hide.join(\",\"));\n if (fragment) u.searchParams.set(\"fragment\", \"1\");\n return u;\n }\n\n /** Overridden by <cf-status>: parameters read from the page URL. */\n extraParams() {\n return [];\n }\n\n connectedCallback() {\n if (!this.getAttribute(\"module\")) return;\n this.load();\n }\n\n attributeChangedCallback(_name, was, now) {\n if (was !== null && was !== now && this.isConnected) this.load();\n }\n\n fallback(text) {\n const a = document.createElement(\"a\");\n a.className = \"cf-embed-link\";\n a.href = this.url(false);\n a.textContent = text;\n this.replaceChildren(a);\n }\n\n async load() {\n this.fallback(\"Open the form\");\n try {\n const res = await fetch(this.url(true), { headers: { Accept: \"text/html\" } });\n await this.swap(res);\n this.dispatchEvent(new CustomEvent(\"cf:loaded\", { detail: { status: res.status } }));\n } catch (err) {\n this.fallback(\"Open the form (could not load it here)\");\n this.dispatchEvent(new CustomEvent(\"cf:error\", { detail: { error: err } }));\n }\n }\n\n /** Replaces the content with the response\'s fragment and wires it. */\n async swap(res) {\n if (res.redirected) {\n window.location.assign(res.url);\n return;\n }\n const html = await res.text();\n const tpl = document.createElement(\"template\");\n tpl.innerHTML = html;\n this.replaceChildren(tpl.content);\n const form = this.querySelector(\"form.cf-form\");\n if (form) form.addEventListener(\"submit\", (e) => this.submit(e, form));\n const widget = this.querySelector(\".cf-turnstile\");\n if (widget) {\n loadTurnstile()\n .then((t) => t.render(widget, { sitekey: widget.dataset.sitekey }))\n .catch(() => {});\n }\n }\n\n async submit(event, form) {\n event.preventDefault();\n const button = form.querySelector(\".cf-submit\");\n if (button) button.disabled = true;\n // Post to the form\'s own /ui route, keeping this element\'s switches\n // (hidden fields stay hidden on a re-render).\n const target = this.url(true);\n try {\n const res = await fetch(target, {\n method: \"POST\",\n body: new URLSearchParams(new FormData(form)),\n headers: { Accept: \"text/html\" },\n });\n await this.swap(res);\n this.dispatchEvent(new CustomEvent(\"cf:submitted\", { detail: { status: res.status } }));\n } catch (err) {\n if (button) button.disabled = false;\n this.dispatchEvent(new CustomEvent(\"cf:error\", { detail: { error: err } }));\n }\n }\n}\n\nclass CfStatus extends CfForm {\n get action() {\n return this.getAttribute(\"action\") || \"status\";\n }\n\n extraParams() {\n const page = new URLSearchParams(window.location.search);\n const token = this.getAttribute(\"token\") || page.get(\"token\");\n return token ? [[\"token\", token]] : [];\n }\n\n fallback(text) {\n super.fallback(text.replace(\"the form\", \"the status page\"));\n }\n\n /** No token on the page: say so instead of fetching a 400. */\n load() {\n if (this.extraParams().length === 0) {\n const p = document.createElement(\"p\");\n p.className = \"cf-help cf-status-empty\";\n p.textContent = this.getAttribute(\"empty\") || \"Open the link from your email to see your status.\";\n this.replaceChildren(p);\n this.dispatchEvent(new CustomEvent(\"cf:loaded\", { detail: { status: 0 } }));\n return Promise.resolve();\n }\n return super.load();\n }\n}\n\n/* Browser push (issue #183) -------------------------------------------\n *\n * <script>window.cf = { auth: () => session.accessToken };</script>\n * <script type=\"module\" src=\"https://api.example.com/ui/cf.js\"></script>\n * <cf-push></cf-push> <!-- or call cf.push.* yourself -->\n *\n * The auth seam is `cf.auth`: an access token, or a function returning\n * one (it may be async). It is read once per request and never stored \u{2014}\n * refresh and storage stay with the page, and this file keeps no\n * credential of its own. The routes it calls take a bearer token and\n * nothing else, so without `cf.auth` a subscribe throws here rather than\n * sending a request that can only be a 401.\n *\n * The five things a push client gets wrong, and where each is handled:\n * base64url \u{2192} Uint8Array (`keyBytes`), permission only on a gesture\n * (`subscribe` asks before its first `await`), re-registering after\n * `pushsubscriptionchange` (the service worker pings, `sync` re-PUTs),\n * iOS Safari needing an installed web app (`supported`, reported as its\n * own reason), and deleting the server-side row (`unsubscribe`).\n */\nconst cf = (window.cf ||= {});\nconst SUBS = \"/v1/notifications/subscriptions\";\nconst ID_KEY = \"cf.push.id\";\nlet serverKeyPromise = null;\nlet listening = false;\n\n/** The API origin: the script\'s own, unless the page named another. */\nfunction apiBase() {\n return cf.base || ORIGIN;\n}\n\n/** The one auth seam. Read per request, never cached, never stored. */\nasync function authHeaders() {\n const token = typeof cf.auth === \"function\" ? await cf.auth() : cf.auth;\n if (!token) throw new Error(\"cf.auth is not set: the page must supply an access token\");\n return { authorization: `Bearer ${token}`, \"content-type\": \"application/json\" };\n}\n\n/** base64url \u{2192} Uint8Array, the only form `applicationServerKey` takes. */\nfunction keyBytes(key) {\n const padded = key.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"=\".repeat((4 - (key.length % 4)) % 4);\n return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));\n}\n\nfunction sameKey(a, b) {\n const x = new Uint8Array(a);\n const y = new Uint8Array(b);\n return x.length === y.length && x.every((v, i) => v === y[i]);\n}\n\n/** iOS and iPadOS, including the iPad that reports itself as a Mac. */\nfunction isIos() {\n return (\n /iP(hone|ad|od)/.test(navigator.userAgent || \"\") ||\n (navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1)\n );\n}\n\nfunction isInstalled() {\n return navigator.standalone === true || window.matchMedia?.(\"(display-mode: standalone)\").matches === true;\n}\n\n/** Why push is, or is not, available here.\n *\n * `ios-needs-homescreen` is deliberately not folded into the flat no:\n * iOS and iPadOS 16.4+ do support this, only from a web app the user has\n * added to the Home Screen, so the page can ask for that instead of\n * telling somebody their phone cannot do it. */\nfunction supported() {\n if (!window.isSecureContext) return { supported: false, reason: \"insecure-context\" };\n if (isIos() && !isInstalled()) return { supported: false, reason: \"ios-needs-homescreen\" };\n if (!(\"serviceWorker\" in navigator)) return { supported: false, reason: \"no-service-worker\" };\n if (!(\"PushManager\" in window)) return { supported: false, reason: \"no-push\" };\n if (!(\"Notification\" in window)) return { supported: false, reason: \"no-notifications\" };\n return { supported: true };\n}\n\n/** granted | denied | default | unsupported. */\nfunction state() {\n return supported().supported ? Notification.permission : \"unsupported\";\n}\n\n/** The venture\'s `applicationServerKey`, fetched once per page. */\nfunction serverKey() {\n serverKeyPromise ||= fetch(`${apiBase()}/v1/notifications/vapid-public-key`)\n .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`no application server key (${res.status})`))))\n .then((body) => body.public_key)\n .catch((err) => {\n serverKeyPromise = null;\n throw err;\n });\n return serverKeyPromise;\n}\n\n/** Registers the subscription with the venture. The route upserts, so\n * this is also the repair for a subscription the browser replaced. */\nasync function put(subscription, options = {}) {\n const json = subscription.toJSON();\n const body = {\n transport: \"webpush\",\n recipient: { web_push: { endpoint: json.endpoint, p256dh: json.keys.p256dh, auth: json.keys.auth } },\n };\n if (options.appId) body.app_id = options.appId;\n if (options.appVersion) body.app_version = options.appVersion;\n const res = await fetch(apiBase() + SUBS, {\n method: \"PUT\",\n headers: await authHeaders(),\n body: JSON.stringify(body),\n });\n // A subscription endpoint is a bearer capability: statuses, never URLs.\n if (!res.ok) throw new Error(`registering for notifications failed (${res.status})`);\n const { id } = await res.json();\n try {\n localStorage.setItem(ID_KEY, id);\n } catch {}\n return { id };\n}\n\n/** The service worker\'s re-subscribe ping. It never holds a token, so a\n * `pushsubscriptionchange` with a page open is re-registered from here. */\nfunction listen() {\n if (listening || !(\"serviceWorker\" in navigator)) return;\n listening = true;\n navigator.serviceWorker.addEventListener(\"message\", (event) => {\n if (event.data?.type === \"cf-push-resubscribed\" && cf.auth) sync().catch(() => {});\n });\n}\n\n/** Ask, subscribe, register. Call it from a click handler: permission is\n * requested before the first `await`, because an `await` spends the user\n * activation the browsers require for it. */\nasync function subscribe(options = {}) {\n const check = supported();\n if (!check.supported) throw new Error(`browser push is unavailable here: ${check.reason}`);\n if (Notification.permission === \"default\") await Notification.requestPermission();\n if (Notification.permission !== \"granted\") throw new Error(`notification permission is ${Notification.permission}`);\n\n listen();\n const registration = await navigator.serviceWorker.register(\n options.swUrl || \"/sw.js\",\n options.scope ? { scope: options.scope } : undefined,\n );\n await navigator.serviceWorker.ready;\n const key = await serverKey();\n const bytes = keyBytes(key);\n let subscription = await registration.pushManager.getSubscription();\n // A rotated VAPID key leaves a subscription nothing can push to and\n // nothing about it looks wrong from here. Replace it rather than\n // re-registering a recipient every send will fail against.\n if (subscription && !sameKey(subscription.options?.applicationServerKey || bytes, bytes)) {\n await subscription.unsubscribe();\n subscription = null;\n }\n subscription ||= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: bytes });\n // The worker keeps the key so it can re-subscribe on its own. Public by\n // definition, and the only thing it is told.\n registration.active?.postMessage({ type: \"cf-push-key\", key });\n return put(subscription, options);\n}\n\n/** Both halves: the browser\'s subscription and the venture\'s row. */\nasync function unsubscribe() {\n const registration = await navigator.serviceWorker?.getRegistration();\n const subscription = registration && (await registration.pushManager.getSubscription());\n let id = null;\n try {\n id = localStorage.getItem(ID_KEY);\n } catch {}\n // No remembered id: the PUT upserts and answers with the row\'s own, so\n // the delete works on a browser whose storage was cleared.\n if (!id && subscription) id = (await put(subscription)).id;\n if (subscription) await subscription.unsubscribe();\n if (id) {\n const res = await fetch(`${apiBase()}${SUBS}/${encodeURIComponent(id)}`, {\n method: \"DELETE\",\n headers: await authHeaders(),\n });\n if (!res.ok && res.status !== 404) throw new Error(`turning notifications off failed (${res.status})`);\n }\n try {\n localStorage.removeItem(ID_KEY);\n } catch {}\n return Boolean(subscription || id);\n}\n\n/** Re-registers the subscription this browser already holds. It never\n * prompts, so it is safe on load \u{2014} and it is what repairs a\n * `pushsubscriptionchange` that happened with no page open. */\nasync function sync() {\n if (!supported().supported || Notification.permission !== \"granted\") return null;\n listen();\n const registration = await navigator.serviceWorker.getRegistration();\n const subscription = registration && (await registration.pushManager.getSubscription());\n return subscription ? put(subscription) : null;\n}\n\n/** One tag for the whole enable block: the button and the three states\n * around it. Every string is an attribute, so the copy is the page\'s. */\nclass CfPush extends HTMLElement {\n connectedCallback() {\n listen();\n this.render();\n }\n\n copy(name, fallback) {\n return this.getAttribute(name) || fallback;\n }\n\n render(note) {\n const check = supported();\n const help = document.createElement(\"p\");\n help.className = \"cf-help\";\n if (!check.supported) {\n help.textContent =\n check.reason === \"ios-needs-homescreen\"\n ? this.copy(\"install\", \"Add this site to your Home Screen to turn notifications on.\")\n : this.copy(\"unsupported\", \"This browser cannot show notifications.\");\n this.replaceChildren(help);\n return;\n }\n if (Notification.permission === \"denied\") {\n help.textContent = this.copy(\"denied\", \"Notifications are blocked in your browser settings.\");\n this.replaceChildren(help);\n return;\n }\n const on = Notification.permission === \"granted\";\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = \"cf-submit\";\n button.textContent = on ? this.copy(\"off-label\", \"Turn notifications off\") : this.copy(\"label\", \"Turn notifications on\");\n button.addEventListener(\"click\", () => this.toggle(on, button));\n help.textContent = note || (on ? this.copy(\"on\", \"Notifications are on for this device.\") : this.copy(\"intro\", \"\"));\n this.replaceChildren(button, help);\n }\n\n async toggle(on, button) {\n button.disabled = true;\n try {\n // `subscribe` runs up to its permission prompt synchronously, so\n // the click\'s user activation is still there when it asks.\n await (on\n ? unsubscribe()\n : subscribe({ swUrl: this.getAttribute(\"sw\") || undefined, appId: this.getAttribute(\"app-id\") || undefined }));\n this.render();\n this.dispatchEvent(new CustomEvent(\"cf:push\", { detail: { state: state() } }));\n } catch (err) {\n this.render(this.copy(\"error\", \"That did not work. Try again.\"));\n this.dispatchEvent(new CustomEvent(\"cf:error\", { detail: { error: err } }));\n }\n }\n}\n\nconst push = { supported, state, subscribe, unsubscribe, sync };\nconst NOTES = \"/v1/notifications\";\n\n/** Every call here goes through `cf.auth`, like the push half. */\nasync function notesFetch(path, init) {\n const response = await fetch(`${apiBase()}${NOTES}${path}`, {\n ...init,\n headers: await authHeaders(),\n });\n if (!response.ok) throw new Error(`notifications: ${response.status}`);\n return response.status === 204 ? null : response.json();\n}\n\n/** One page, newest first. `cursor` comes from the previous page. */\nfunction list({ cursor, unread, limit } = {}) {\n const query = new URLSearchParams();\n if (cursor) query.set(\"cursor\", cursor);\n if (unread) query.set(\"unread\", \"true\");\n if (limit) query.set(\"limit\", String(limit));\n return notesFetch(query.size ? `?${query}` : \"\");\n}\n\nconst notifications = {\n list,\n unreadCount: () => notesFetch(\"/unread-count\").then((body) => body.unread),\n markRead: (id) => notesFetch(`/${encodeURIComponent(id)}/read`, { method: \"POST\" }),\n markAllRead: () => notesFetch(\"/read-all\", { method: \"POST\" }),\n archive: (id) => notesFetch(`/${encodeURIComponent(id)}`, { method: \"DELETE\" }),\n};\n\n/** The unread count, polled only while the tab is visible.\n *\n * A hidden tab polling every minute is a background request the person\n * cannot see and did not ask for; the count is re-read on the way back,\n * so nothing is stale by the time it is looked at.\n */\nfunction watchUnread(onCount, every = 60000) {\n let timer = null;\n const tick = () =>\n notifications\n .unreadCount()\n .then(onCount)\n .catch(() => {});\n const pause = () => {\n clearInterval(timer);\n timer = null;\n };\n // The first read has to succeed before anything is scheduled. A page\n // that cannot authenticate \u{2014} a static example, a signed-out visitor,\n // `cf.auth` returning null \u{2014} would otherwise poll into a wall forever,\n // and one live `setInterval` is enough to keep a page (and a CI step\n // that waits for the event loop to drain) alive indefinitely.\n const start = async () => {\n if (timer) return;\n try {\n onCount(await notifications.unreadCount());\n } catch {\n return;\n }\n timer = setInterval(tick, every);\n };\n // Stopping has to take the listener with it. Clearing the interval alone\n // leaves this attached to the *document*, which outlives the element: a\n // removed bell would start polling again on the next visibility change,\n // forever, writing counts into a node nobody can see.\n const onVisibility = () => (document.visibilityState === \"hidden\" ? pause() : start());\n document.addEventListener(\"visibilitychange\", onVisibility);\n if (document.visibilityState !== \"hidden\") start();\n return () => {\n pause();\n document.removeEventListener(\"visibilitychange\", onVisibility);\n };\n}\nnotifications.watch = watchUnread;\n\n/** The bell and its panel.\n *\n * The panel is a `<dialog>` opened with `show()`, and nothing here sets\n * `display` on it: an author rule beats the UA\'s own hiding, so a\n * `display` of our own would leave a closed dialog painted.\n */\nclass CfNotifications extends HTMLElement {\n connectedCallback() {\n if (this.dataset.ready) return;\n this.dataset.ready = \"1\";\n this.innerHTML =\n \'<button type=\"button\" aria-haspopup=\"dialog\"><span class=\"cf-bell\">\\u{1F514}</span>\' +\n \'<span class=\"cf-count\" aria-live=\"polite\" hidden></span></button>\' +\n \'<dialog class=\"cf-panel\"><ul></ul>\' +\n \'<footer><button type=\"button\" class=\"cf-all\">Mark all read</button></footer></dialog>\';\n this.bell = this.querySelector(\"button\");\n this.count = this.querySelector(\".cf-count\");\n this.panel = this.querySelector(\"dialog\");\n this.items = this.querySelector(\"ul\");\n\n this.bell.addEventListener(\"click\", () => this.open());\n this.querySelector(\".cf-all\").addEventListener(\"click\", async () => {\n await notifications.markAllRead();\n this.show(0);\n await this.load();\n });\n // Focus goes back to the bell however the dialog closed \u{2014} Escape, the\n // backdrop, or our own code \u{2014} so a keyboard user is never dropped at\n // the top of the document.\n this.panel.addEventListener(\"close\", () => this.bell.focus());\n\n this.stop = watchUnread((n) => this.show(n));\n if (cf.realtime?.subscribe) {\n cf.realtime.subscribe(`notifications:${this.getAttribute(\"account\") || \"\"}`, () =>\n notifications.unreadCount().then((n) => this.show(n)),\n );\n }\n }\n\n disconnectedCallback() {\n this.stop?.();\n }\n\n /** `99+` because a bell is not a place to read a four-digit number. */\n show(n) {\n this.count.textContent = n > 99 ? \"99+\" : String(n);\n this.count.hidden = n === 0;\n this.bell.setAttribute(\"aria-label\", `Notifications, ${n} unread`);\n }\n\n async open() {\n this.panel.showModal();\n await this.load();\n }\n\n async load() {\n const { notifications: rows = [] } = await notifications.list({\n limit: Number(this.getAttribute(\"page-size\")) || 20,\n });\n this.items.replaceChildren(\n ...rows.map((row) => {\n const li = document.createElement(\"li\");\n if (!row.read_at) li.className = \"cf-unread\";\n // The item\'s own language, not the page\'s: one list can hold an\n // Indonesian booking and an Arabic one. `dir` comes from the API\n // because no browser API answers it.\n if (row.locale) {\n li.lang = row.locale;\n li.dir = row.dir || \"ltr\";\n }\n const link = document.createElement(\"button\");\n link.type = \"button\";\n link.textContent = `${row.title} \u{2014} ${row.body}`;\n link.addEventListener(\"click\", async () => {\n await notifications.markRead(row.id);\n this.show(await notifications.unreadCount());\n if (row.url) location.assign(row.url);\n });\n li.append(link);\n return li;\n }),\n );\n if (!rows.length) {\n this.items.innerHTML = `<li class=\"cf-empty\">${this.getAttribute(\"empty\") || \"Nothing yet.\"}</li>`;\n }\n }\n}\n\ncf.notifications = notifications;\ncf.push = push;\n\nif (!customElements.get(\"cf-form\")) customElements.define(\"cf-form\", CfForm);\nif (!customElements.get(\"cf-status\")) customElements.define(\"cf-status\", CfStatus);\nif (!customElements.get(\"cf-push\")) customElements.define(\"cf-push\", CfPush);\nif (!customElements.get(\"cf-notifications\"))\n customElements.define(\"cf-notifications\", CfNotifications);\n\nexport { CfForm, CfStatus, CfPush, push };\n";Expand description
The embed served at /ui/cf.js (issue #73); empty until then.