kukuroo
GitHub

Self-hosted web push worker,
works on iOS.

A push endpoint you own, in one Cloudflare Worker.

  • Notifications you have to act on should not be buried in a Telegram channel, a Discord server, or email.
  • The other way onto a lock screen is shipping a native app whose only job is that one line.
No server to keep alive.

A Worker and a KV namespace. Nothing to host, renew, or restart at 3am.

No company in the middle.

Your keys stay in your Worker, and the payload is encrypted to the device. Nobody's dashboard sees your alerts.

No app to build.

iOS has rendered these natively since 18.4. No App Store, no $99 a year for four words on a lock screen.

Send a push notification in seconds

From cron, CI, a Raspberry Pi, your backend, a shell alias. Anything that can make an HTTP call can ring your phone.

bash
$ curl -X POST https://push.example.com/push/send \
  -H "authorization: Bearer $KUKUROO_SEND_TOKEN" \
  -d '{"notification":{"title":"Task W13 finished",
       "body":"W14 is unblocked and ready to start",
       "navigate":"https://push.example.com/"}}'
{"delivered":1,"removed":0,"failures":[]}

One-Click Integration

Setup takes less than a minute. One command writes the Worker, generates every key, installs them, provisions storage, and deploys.

bash
$ npx kukuroo init my-push

That's it. You now have a push server of your own. Open the printed URL in Safari on your iPhone, Add to Home Screen, and open it from the icon. Apple's rule, once per device.

Already have a Worker? Mount it.

If you already run a site on Cloudflare Workers, Kukuroo mounts into the router you have. Devices enroll on your hostname, which is where a notification tap should land.

Hand it your requests first

handle returns null outside its prefix, so your own routing is untouched.

src/worker.ts
import { mountKukuroo } from "kukuroo";

const kukuroo = mountKukuroo({ prefix: "/push" });

export default {
  async fetch(request, env) {
    const hit = await kukuroo.handle(request, env);
    return hit ?? yourRouter(request, env);
  },
};
Bind KV, then generate the keys

The binding name is fixed; the namespace provisions on your next deploy.

wrangler.jsonc
"kv_namespaces": [{ "binding": "KUKUROO_SUBS" }]

# then, from that directory
$ npm install kukuroo && npx kukuroo init --mounted
Serve enrollment on your origin

Return the bundled page from a route of yours, or build your own against /push/subscribe.

src/worker.ts
import { enrollmentPage } from "kukuroo";

return new Response(enrollmentPage({
  subscribePath: "/push/subscribe",
  publicKeyPath: "/push/public-key",
}), { headers: { "content-type": "text/html" } });

The full mounted setup, step by step →

Prerequisites

Kukuroo supports a short list, on purpose:

  • Cloudflare Workers. The only place it runs. No Docker image, no binary, no other host.
  • Safari. Declarative Web Push has shipped nowhere else yet. Other browsers are turned away at the enrollment page rather than left half working.
  • iOS 18.4 or later, with the page on your Home Screen and opened from the icon. A Safari tab will not do, and that is Apple's rule rather than ours. macOS Safari 18.5 receives too, if you can think of a reason to want it.
  • About 50 devices per send if you are on a free-plan Worker. That is the subrequest ceiling, and about 47 more phones than one person owns.