Skip to main content

CF_JS

Constant CF_JS 

Source
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.