Skip to content
DocsconceptsDocumentation

Push notifications

Configure defineNotify in lunora/notify.ts for Web Push + FCM, register browser subscriptions, and send via ctx.notify / ctx.push from an action.

Last updated:

@lunora/notify adds multi-channel notifications, wrapping the @visulima/notification engine. Web Push and FCM come first, alongside chat, in-app inbox, and webhook channels; both are edge-safe under workerd (fetch + Web Crypto, no node:*). It exposes two facades on the function context:

  • ctx.notify: the multi-channel facade (send, chat, inApp, webhook, push).
  • ctx.push: the device-push sub-facade (register, send, broadcast, list, unregister).

APNs (node:http2) and SMS, plus the BullMQ / pg-boss / SQS queue adapters, are Node-only and are not wired into the edge facade. Route heavy fan-out or those channels through @lunora/queue instead.

Configure defineNotify

Declare the edge channels and a subscription store in lunora/notify.ts. webPushFromEnv / fcmFromEnv read config from the environment, and d1SubscriptionStore persists device subscriptions in D1 (with lazy table creation).

// lunora/notify.ts
import { defineNotify, webPushFromEnv, fcmFromEnv, d1SubscriptionStore } from "@lunora/notify";

export default defineNotify({
    webPush: (env) => webPushFromEnv(env), // VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT
    fcm: (env) => fcmFromEnv(env), // FCM_PROJECT_ID / FCM_ACCESS_TOKEN (prefer a getAccessToken in prod)
    store: (env) => d1SubscriptionStore(env.DB),
});

VAPID / FCM config via .dev.vars

lunora dev scaffolds these keys into .dev.vars from @lunora/config's package-secrets registry. Generate a VAPID keypair once with npx web-push generate-vapid-keys:

VAPID_PUBLIC_KEY=<your-vapid-public-key>
VAPID_PRIVATE_KEY=<your-vapid-private-key>
VAPID_SUBJECT=mailto:you@example.com
FCM_PROJECT_ID=<your-firebase-project-id>
FCM_ACCESS_TOKEN=<your-fcm-access-token>

Register a browser push subscription

On the client, subscribeToPush from @lunora/notify/web registers a service worker and returns a Web Push subscription. Send it to a mutation (a storage write) and persist it with ctx.push.register:

// client
import { subscribeToPush } from "@lunora/notify/web";

const { replacedEndpoint, subscription } = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
await client.mutation("registerDevice", { replacedEndpoint, subscription });

replacedEndpoint is set only after a VAPID key rotation, when the helper drops the stale browser subscription and mints a new one. The new subscription has a new endpoint — hence a new store id — so it never upserts over the old row, and every send to that row now answers 403 VapidPkHashMismatch, which is (correctly) not a "gone" signal, so nothing prunes it either. Unregister it:

// lunora/registerDevice.ts
import { webPushId } from "@lunora/notify";

import { mutation, v } from "@/lunora/_generated/server";

export const registerDevice = mutation
    .input({ replacedEndpoint: v.optional(v.string()), subscription: v.any() })
    .mutation(async ({ ctx, args: { replacedEndpoint, subscription } }) => {
        if (replacedEndpoint !== undefined) {
            await ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId });
        }

        await ctx.push.register({ subscription, userId: ctx.auth?.userId });
    });

unregister's owner argument is required, and the row is removed only when it carries that same owner. A subscription id is derived from the endpoint, so replacedEndpoint is a caller-controlled key and nothing about it proves the browser that sent it ever held the subscription it names — without the scope, anyone who could guess or observe another user's endpoint could silence that device's notifications. A row owned by someone else is left alone silently, so the call cannot be used to probe which endpoints exist. Register with the same userId you unregister with; devices registered anonymously all share the one anonymous scope and get no separation from this check.

Send from an action

Notification sends are external I/O, so they belong in actions. The notify_send_outside_action advisor lint enforces this. ctx.push.broadcast fans out to every stored subscription, reusing the engine's retry + circuit-breaker middleware and pruning subscriptions the push service reports as gone (Web Push 404/410, FCM's NOT_FOUND for a dead token):

// lunora/announce.ts
import { action, v } from "@/lunora/_generated/server";

export const announce = action.input({ title: v.string(), body: v.string() }).action(async ({ ctx, args: { title, body } }) => {
    const result = await ctx.push.broadcast({ title, body });
    // result: { total, sent, pruned, failed, outcomes }
    return result;
});

A single targeted send, and a multi-channel send through ctx.notify:

await ctx.push.send(subscriptionId, { title: "Hi", body: "…" });

await ctx.notify.send({
    push: { title: "New drop", body: "…", to: pushTarget },
    chat: { text: "New drop shipped" },
});

Queue-backed fan-out

Move a large broadcast off the request path with @lunora/queue.

Each queue message delivers exactly one bounded page (250 subscriptions by default), so the consumer has to re-enqueue while nextFilter is set — discarding the result silently stops the broadcast after the first page and still acks the message as a success:

// producer (mutation/action)
await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });

// consumer (lunora/queues.ts)
for (const message of batch.messages) {
    const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, message.body);

    if (nextFilter !== undefined) {
        // More pages remain — enqueue the continuation. Each message still does
        // only ONE bounded page of work. Pass `nextFilter` verbatim: it carries
        // the cursor AND the remaining `filter.limit` budget.
        await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: message.body.payload });
    }

    // Redeliver ONLY the recipients that failed. Retrying the whole page would
    // re-POST every device it already reached.
    if (failedIds.length > 0) {
        await enqueuePushBroadcast(ctx.queues.push, { payload: message.body.payload, retryIds: failedIds });
    }

    message.ack();
}

A page never throws on a partial failure — it reports failedIds alongside nextFilter, so one permanently-failing device cannot strand the cursor and halt the broadcast for everyone behind it. Re-enqueue those ids with retryIds; that job throws while all of them still fail, so only the failing device reaches the dead-letter queue — once any recipient recovers it resolves instead, and the narrower retry never re-sends to a device the message already reached. A page that merely pruned gone subscriptions is a success.

filter.limit is an overall audience cap here too: nextFilter carries the remaining budget and is undefined once it is spent, so the walk stops rather than granting each message a fresh limit.