Web SDK
@opennotification/web — browsers and PWAs. Nothing runs at import time, so importing during SSR is safe, and only types come from @opennotification/core, so no validation library lands in your bundle.
Before this works you need HTTPS, a manifest, the VAPID pair and your origin in CORS_ORIGINS: Web platform setup.
1 Install
bun add @opennotification/web
or straight from a CDN:
<script type="module">
import { init } from "https://cdn.jsdelivr.net/npm/@opennotification/web/+esm";
</script>
2 The service worker
Copy the prebuilt bundle to your web root:
bun --filter @opennotification/web build:sw
cp packages/sdk-web/dist/sw.js public/sw.js
The SDK registers it as /sw.js?api=<endpoint> — the worker reads its API base URL from that query string, so one build works for every deployment.
Already have a service worker? Import the handlers into it instead:
// sw.ts
import { registerPushHandlers } from "@opennotification/web/sw";
registerPushHandlers({
apiUrl: "https://push.example.com",
defaults: { icon: "/icons/192.png", badge: "/icons/badge.png" },
});
The handlers cover:
| Event | Does |
|---|---|
push | Shows the notification (title, body, icon, image, badge, tag, actions, silent) and reports delivered. |
notificationclick | Reports opened (body) or clicked (action), focuses an existing tab on the target URL before opening a new one. |
notificationclose | Reports dismissed. |
pushsubscriptionchange | Re-subscribes and calls /v1/subscriptions/rotate so a browser that silently re-issued its subscription does not cost you the subscriber. |
3 Initialise and check capability
import { init, mountInstallPrompt } from "@opennotification/web";
const on = init({
endpoint: "https://push.example.com",
appKey: "pk_live_…", // public key
vapidKey: "BNcRd…", // the app's VAPID public key
serviceWorkerPath: "/sw.js", // default
});
const cap = on.capability();
// { supported: true, reason: null, platform: "desktop" }
// { supported: false, reason: "IOS_NEEDS_INSTALL", platform: "ios" }
reason | Meaning | What to do |
|---|---|---|
null | Push works. | Show your enable button. |
IOS_NEEDS_INSTALL | iOS Safari tab. Push exists only for an installed PWA. | Show the install guide (below). |
IOS_UNSUPPORTED_BROWSER | Chrome/Firefox/Edge on iOS — WebKit shells that cannot install. | Tell them to open the site in Safari. |
INSECURE_CONTEXT | Served over HTTP. | Fix your hosting. |
UNSUPPORTED_BROWSER | No service worker / Push API. | Hide the button. |
The iOS install guide
const guidance = on.guidance("tr"); // "en" | "tr"
if (guidance?.actionable) mountInstallPrompt({ guidance });
guidance() returns the Share → Add to Home Screen steps as text; mountInstallPrompt() renders a small sheet if you do not want to build your own. actionable is false for reasons the user cannot fix (an unsupported browser).
4 Subscribe — from a click
document.querySelector("#enable")!.addEventListener("click", async () => {
const result = await on.subscribe({
externalId: "user_123",
tags: { plan: "premium" },
language: navigator.language,
});
if (!result.ok) {
switch (result.error.code) {
case "permission_denied": // blocked for this origin — show how to unblock
case "permission_dismissed": // closed the prompt, or called outside a gesture
case "unsupported":
case "service_worker_failed":
case "push_subscribe_failed":
case "api_error":
case "network_error":
}
}
});
subscribe("user_123") is shorthand for { externalId }. The SDK registers the worker, waits for it to be ready, asks for permission, subscribes with the VAPID key and POSTs the subscription with browser, standalone (installed as PWA), timezone and language.
Notification.requestPermission() outside a click/tap handler is silently rejected on Safari and increasingly throttled in Chrome. The SDK returns permission_dismissed when that happens.
5 Identity, tags, unsubscribe
await on.login("user_456");
await on.setTags({ plan: "premium", streak: 42 });
await on.removeTags(["streak"]);
await on.setLanguage("tr");
await on.logout();
await on.unsubscribe(); // unsubscribes in the browser and deletes the row
on.permission(); // "granted" | "denied" | "default" | "unsupported"
await on.isSubscribed();
on.subscriptionId(); // from localStorage
const stop = on.trackSessions(); // count a session now and on every return to the tab
await on.trackSession(); // or ping once yourself
Semantics: Identity. Sessions are deduplicated on the server (30-minute idle rule). Silent (background) campaigns never reach browsers — the fan-out skips web subscriptions, because a push that shows nothing gets the subscription revoked.
Payload the worker receives
For reference, what the server encrypts into each push:
{
"title": "Weekend flash sale",
"body": "30% off everything until Sunday night.",
"icon": "https://…/icon.png",
"image": "https://…/sale.jpg",
"badgeCount": 3,
"tag": "<collapseId>",
"actions": [{ "action": "shop", "title": "Shop now" }],
"data": { "msgId": "<signed>", "campaignId": "…", "url": "https://…/sale", "a:shop": "https://…/sale?cta=1", "screen": "sale" }
}
Button targets travel as data["a:<id>"]; the click handler picks the right one. silent: true is added when the campaign's sound is none; tag comes from collapseId so a newer push replaces an older one.
Notes
- Errors come back as
Result, never thrown. - The subscription id is kept in
localStorage; clearing site data means a re-subscribe, which upserts the same endpoint. - Nothing in the SDK reads or stores the VAPID private key — only the public one ever reaches the browser.