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.appServer
// 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.