@notiflyio/web-push
v0.2.0
Published
App-side Notifly wiring for PWAs and their native shells: shell push token lifecycle, session-authed relay/inbox-session route cores, device-token validators, and the useNotiflySession React hook
Readme
@notiflyio/web-push
App-side Notifly wiring for PWAs and their native shells — the ~350 lines every fleet app used to hand-copy, as a zero-dependency package with three entries:
@notiflyio/web-push(browser-safe):createShellPushClient— shell detection, push token lifecycle (rotation-aware register, provider-tagged sign-out drop, preference-gated permission ask, iOS permission-state round-trip), configurable foreground-push handling, deep links, the device-token validators,pushPayloadLink.@notiflyio/web-push/client: the same browser surface WITHOUT the device-token format validators — so the APNs regex literal that client-bundle ban gates grep for cannot reach a browser chunk through this subpath.@notiflyio/web-push/server(Node):createNotiflyServer(ensure subscriber / per-device token endpoints / workflow trigger with the FCM data override),buildInboxSession+computeSubscriberHash(HMAC), andhandleTokenRelay— the framework-agnostic core of the token relay route with the fleet's incident rules baked in (token-less DELETE = logged no-op, loud 502s, ensure-subscriber retry-once).@notiflyio/web-push/react:useNotiflySession(fetches your inbox-session route, returns<NovuProvider>props incl. self-hostedsocketUrl) and<NotiflyShellPushMount>(arms the shell push plumbing).
Works with @notiflyio/react ≥ 3.17.1 (the first version that honors a
self-hosted socketUrl verbatim and maps ws payload→data). The native
shells speak the matching contract via @notiflyio/capacitor-push (Android)
and the NotiflyShellPush Swift package (iOS/macOS).
Install
npm install @notiflyio/web-push @notiflyio/reactEnv (server-only, never NEXT_PUBLIC): NOTIFLY_SECRET_KEY, NOTIFLY_APP_ID,
optional NOTIFLY_API_URL / NOTIFLY_WS_URL (default https://api.notifly.io
/ https://ws.notifly.io — keep the https:// scheme on the ws URL; socket.io
does its own wss upgrade and a literal wss:// breaks the polling handshake).
Wire-up (Next.js App Router shown; any framework works)
1. Inbox-session route — app/api/user/inbox-session/route.ts:
import { buildInboxSession } from "@notiflyio/web-push/server";
export async function POST() {
const session = await getSession(); // your auth
if (!session?.user) return Response.json({ error: "unauthorized" }, { status: 401 });
const inbox = buildInboxSession({
secretKey: process.env.NOTIFLY_SECRET_KEY,
applicationIdentifier: process.env.NOTIFLY_APP_ID,
apiUrl: process.env.NOTIFLY_API_URL ?? "https://api.notifly.io",
wsUrl: process.env.NOTIFLY_WS_URL ?? "https://ws.notifly.io",
subscriberId: session.user.id, // from the session, NEVER the body
});
if (!inbox) return Response.json({ error: "not configured" }, { status: 503 });
return Response.json(inbox);
}2. Token relay route — app/api/user/notifly-push-token/route.ts:
import { createNotiflyServer, handleTokenRelay } from "@notiflyio/web-push/server";
const notifly = process.env.NOTIFLY_SECRET_KEY
? createNotiflyServer({ secretKey: process.env.NOTIFLY_SECRET_KEY, apiUrl: process.env.NOTIFLY_API_URL })
: null;
async function relay(method: "POST" | "DELETE", request: Request) {
const session = await getSession();
if (!session?.user) return Response.json({ error: "unauthorized" }, { status: 401 });
const result = await handleTokenRelay(notifly, {
method,
body: await request.json().catch(() => null),
subscriberId: session.user.id,
profile: { email: session.user.email, name: session.user.name },
});
if (result.error) reportError(result.error); // your reporter
// `result.code` is the contract ('registered' | 'removed' | 'removed_noop' |
// 'invalid_body' | 'invalid_token' | 'not_configured' | 'register_failed' |
// 'remove_failed'). Map it into YOUR envelope; status/body are only defaults.
return Response.json(result.body, { status: result.status });
}
export const POST = (request: Request) => relay("POST", request);
export const DELETE = (request: Request) => relay("DELETE", request);3. Signed-in layout — arm the shells + boot the bell:
import { NovuProvider } from "@notiflyio/react";
import { useNotiflySession, NotiflyShellPushMount } from "@notiflyio/web-push/react";
function NotificationRoot({ children }) {
const { status, providerProps } = useNotiflySession();
return (
<>
<NotiflyShellPushMount appPrefix="myapp" jsGlobal="MyappPush" syncUrl="/api/user/notifly-sync" />
{status === "ready" ? <NovuProvider {...providerProps}>{children}</NovuProvider> : children}
</>
);
}4. Sign-out — while the session cookie is still alive:
import { createShellPushClient } from "@notiflyio/web-push";
await createShellPushClient({ appPrefix: "myapp" }).dropStoredPushToken();That's the whole app-side integration. The bell UI itself stays yours (headless
@notiflyio/react hooks); triggers go through createNotiflyServer(...)
.triggerWorkflow(...) or @notiflyio/api.
Contract
Event names, endpoint shapes, and validator vocabulary are the fleet-locked
pwa-notifications contracts (proven live 2026-07-23, SDK-ported 2026-07-31):
push-apns-token/push-apns-error/push-notification window CustomEvents from
the shells; per-device token endpoints
POST|DELETE /v1/subscribers/:id/credentials/:provider/token (token in BODY,
both 200); subscriberHash = HMAC-SHA256(secretKey, subscriberId) hex.
push-apns-token is the ONE token event on every platform (contracts §6a) and
carries { token, provider } — an Android token arrives on that apns-named
event with provider: 'fcm'. Since 0.1.1 arm() reads that object shape as
well as the bare token string older shells send (where the event name is the
only provider signal), and relays a given provider+token once even when a shell
emits both the primary event and its deprecated push-fcm-token alias. A detail
it cannot read — no token, or a provider outside apns/fcm — goes to
onError rather than being relayed under a guessed provider. readShellTokenDetail
is exported for apps that write their own listener.
Adopting 0.2 over an app that already has push (0.1 → 0.2 migration)
0.1.x was greenfield-only by construction: four wired fleet apps — including uNotes, the implementation this package was mirrored from — independently graded it non-adoptable, because adopting it meant changing a live wire shape, a live storage layout, or a proven client behavior. 0.2 is the design round that closes that. Breaking changes are listed below; each one has a bridge.
Everything an app must decide
1. Storage layout — the one that corrupts data silently. If your app already
stores a device token, look at its VALUE, not just its key name. 0.1's key
<appPrefix>_notifly_device_token NAME-matched uNotes' live key while storing a
bare token where the app stored JSON.stringify({token, provider}). Adopting
stranded every installed device's rotation anchor (the read guarded on a
separate _device_provider key that live devices did not have, so previousToken
was omitted and the old token never deregistered) and then broke the app's own
reader in the other direction. Both silent. Name your layout:
import { createShellPushClient, jsonBlobDeviceCodec } from "@notiflyio/web-push";
createShellPushClient({
appPrefix: "unotes",
storage: {
codec: jsonBlobDeviceCodec, // one key holding {token, provider}
tokenKey: "unotes_notifly_device_token", // your live key name
prefKey: "notifly_push_enabled", // unprefixed legacy keys stay unprefixed
promptedKey: "notifly_push_prompted_at",
legacyKeys: ["notifly_device_token"], // read once, adopt, remove
},
});The default is bareTokenDeviceCodec — 0.1's exact two-key layout, unchanged,
including its "no provider tag means read null" guard. A greenfield app changes
nothing.
2. Server posture. handleTokenRelay takes any NotiflyServerAdapter, not
only createNotiflyServer(...). An app standardized on @notiflyio/api writes a
thin adapter and keeps its own client, error type and lazy per-request env reads:
import type { NotiflyServerAdapter } from "@notiflyio/web-push/server";
const adapter: NotiflyServerAdapter = {
ensureSubscriber: (subscriberId, profile) => api().subscribers.create({ subscriberId, ...profile }),
registerDeviceToken: ({ subscriberId, provider, ...rest }) =>
api().subscribers.credentials.registerToken({
subscriberId, providerId: provider, registerSubscriberDeviceTokenRequestDto: rest,
}),
removeDeviceToken: ({ subscriberId, provider, token, integrationIdentifier }) =>
api().subscribers.credentials.removeToken({
subscriberId, providerId: provider,
removeSubscriberDeviceTokenRequestDto: { token, integrationIdentifier },
}),
triggerWorkflow: (name, subscriberId, payload) =>
api().trigger({ name, to: { subscriberId }, payload }),
};3. Your error envelope. TokenRelayResult and handleInboxSession carry a
machine-readable code. Map it into your own vocabulary and ignore status/body
entirely — 0.1 forced apps with a locked envelope to either change their public
wire shape or string-match English prose.
Breaking changes
| Change | Bridge |
| --- | --- |
| InboxSession fields are applicationIdentifier / backendUrl? / socketUrl (were appId / apiUrl / wsUrl) — the locked fleet contract every wired app already returned | useNotiflySession is a TOLERANT READER accepting both generations, so route and client deploy in either order. readInboxSession(raw) is exported for hand-written clients. |
| buildInboxSession config takes applicationIdentifier | rename one key |
| NotiflyProviderProps.apiUrl is optional, absent when the session has no backendUrl | spread as before |
| onError(provider, failure) receives { reason, detail } | read failure.detail for the old value |
| registerShellPushToken rejects on a transport failure | it still returns false for a non-2xx; arm() routes both to onError |
| ensureSubscriber PATCHes on 409 only, no longer on 400 | a 400 now throws — it was always a genuine create failure being converted into silent success |
| withNotiflyRetry does NOT retry errors it cannot classify | pass statusOf to teach it your adapter's error type |
| handleTokenRelay's third argument is an options object | a bare log function is still accepted |
| useNotiflySession's argument may be an options object | a bare route-url string is still accepted |
| withNotiflyRetry's third argument may be an options object | a bare maxAttempts number is still accepted |
Added
storage(key names, value codec,legacyKeysmigration) with thebareTokenDeviceCodec/jsonBlobDeviceCodecbuilt-ins.- Push-preference tri-state:
readPushPref()/writePushPref().arm()'s prompt gates onpref === null, so a user who explicitly turned push OFF is never re-prompted — in 0.1, "off" and "never asked" were the same state. - Per-session iOS
push-permission-stateround-trip (stale-token recovery) andpush-permission-requestresult recording. onForegroundPush:'navigate'(default, 0.1 behavior),'notify', or your own presenter receiving{title, body, link}.dropStoredPushToken(providerOverride?).integrationIdentifier+maxDeviceTokenson register,integrationIdentifieron remove — settable per-route (which overrides the client) or passed through from the relay body, where they are bounds-checked.triggerWorkflow'snotificationIdis OPTIONAL, and it takes anidempotencyKeysent as the platform'sIdempotency-Keyheader.handleInboxSession(config)— thecode-first result, distinguishingunauthenticated(401) fromnot_configured(503).@notiflyio/web-push/clientsubpath and"sideEffects": false.
Known divergence
computeSubscriberHash here is SYNCHRONOUS (node:crypto); @notiflyio/api
exports a function of the same name that is ASYNC (Web Crypto, so it also runs
on edge runtimes). Output is byte-identical; they are not drop-in substitutes.
Nothing is re-exported from @notiflyio/api here, because importing both under
one name is how a sync/async mix-up becomes a hash of [object Promise].
