Sub-skills

Web Push in depth

For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill push`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/push.md verbatim with curl; don't summarize them).
Run `cubek check` after every edit until it is clean.

Web Push notifications (Chrome, Firefox, Edge, Safari; iPhone/iPad from iOS 16.4 as a Home Screen app). Complete app: examples/push in the cubek repository. Signatures, ECDH, HKDF, AES-GCM by hand: crypto.md.

push.publicKey()                    the project's VAPID key, base64url (made on the first deploy using push; dev: .cubek/push.key)
push.send(sub, payload, { ttl?: "1d", urgency?, topic? }) → { ok, status, gone, reason, dryRun? }
                                    payload: object (JSON), string or Bytes, ≤ 3993 bytes; gone: delete the subscription
                                    reason: the push service's refusal ("VapidPkHashMismatch", "BadJwtToken"...), "" if ok; also in cubek logs
                                    one of the execution's 20 outbound calls: fan out with tasks
secret PUSH_SUBJECT                 contact for push services (mailto:you@x.com); default https://<project>.cubek.app

Server

// schema.ts
import { table, t } from "cubek"

export const subscriptions = table("subscriptions", {
  id: t.id(),
  endpoint: t.string().unique(),
  p256dh: t.string(),
  auth: t.string(),
})
// src/mounts.ts
import { mount } from "cubek"

export const pushButton = mount<{ publicKey: string }>("push")
// src/app.tsx
import { app, db, Mount, push, task, validate } from "cubek"
import { pushButton } from "./mounts"

const subInput = validate.object({ endpoint: validate.string({ url: true }), keys: validate.object({ p256dh: validate.string(), auth: validate.string() }) })

app.get("/", (c) =>
  c.render(
    <html>
      <head>
        <link rel="manifest" href="/manifest.webmanifest" />
      </head>
      <body>
        <Mount at={pushButton} props={{ publicKey: push.publicKey() }}>
          <button type="button">Enable notifications</button>
        </Mount>
      </body>
    </html>,
  ),
)

app.post("/api/push/subscribe", async (c) => {
  const b = subInput.safeParse(await c.body())
  if (!b.success) return c.json({ error: b.error }, 400)
  const { endpoint, keys } = b.data
  if ((await db.subscriptions.findOne({ where: { endpoint } })) === null) await db.subscriptions.insert({ endpoint, ...keys })
  return c.json({ ok: true }, 201)
})

// One task per subscription; a 5xx/429 throws so the task retries.
task.on("push", async (p: { id: string; title: string; url: string }) => {
  const s = await db.subscriptions.get(p.id)
  if (s === null) return
  const r = await push.send({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, { title: p.title, url: p.url }, { ttl: "1d" })
  if (r.gone) await db.subscriptions.delete(s.id)
  else if (!r.ok) throw new Error(`push: ${r.status} ${r.reason}`)
})

app.post("/api/announce", async (c) => {
  const page = await db.subscriptions.find({ orderBy: { id: "asc" }, limit: 500 }) // page by id for more
  for (const s of page) await task.run("push", { id: s.id, title: "New post", url: "/posts" })
  return c.json({ queued: page.length })
})

Browser

// client/push.ts
import { onMount } from "cubek/client"
import { pushButton } from "cubek/server"

const bytes = (k: string) => Uint8Array.from(atob(k.replace(/-/g, "+").replace(/_/g, "/")), (ch) => ch.charCodeAt(0))
const sameKey = (a: ArrayBuffer | null, b: Uint8Array) => a !== null && new Uint8Array(a).join() === b.join()

onMount(pushButton, (el, props) => {
  if (!("serviceWorker" in navigator) || !("PushManager" in window)) return // iOS: Home Screen app only
  const reg = navigator.serviceWorker.register("/sw.js").then(() => navigator.serviceWorker.ready)
  el.querySelector("button")?.addEventListener("click", async () => {
    // A click (user gesture) is required on iOS.
    if ((await Notification.requestPermission()) !== "granted") return
    const r = await reg
    const key = bytes(props.publicKey)
    let sub = await r.pushManager.getSubscription()
    // One made with another key (an earlier app on this origin) never receives (VapidPkHashMismatch).
    if (sub !== null && !sameKey(sub.options.applicationServerKey, key)) {
      await sub.unsubscribe()
      sub = null
    }
    sub ??= await r.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
    await fetch("/api/push/subscribe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(sub.toJSON()) })
  })
})
// public/sw.js (at the root: its scope is the whole site; served no-cache)
self.addEventListener("push", (e) => {
  const n = e.data ? e.data.json() : {}
  // Always show one: Safari revokes subscriptions that get silent pushes.
  e.waitUntil(self.registration.showNotification(n.title || "Update", { body: n.body, data: { url: n.url || "/" } }))
})
self.addEventListener("notificationclick", (e) => {
  e.notification.close()
  e.waitUntil(self.clients.openWindow(e.notification.data.url))
})
// public/manifest.webmanifest
{ "name": "My app", "start_url": "/", "display": "standalone", "icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }] }

iPhone: display: "standalone" manifest, the user adds the site to the Home Screen (Share → Add to Home Screen) and subscribes from there.

Tests

Under cubek test, push.send records and answers 201 (410 when the endpoint path has a /gone segment): import { pushSent } from "cubek/test" returns the sends so far. Real pushes: cubek dev and production send them; a preview only logs them (dryRun: true, status 0) unless deployed with --side-effects.