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 — both are edge-safe under workerd (fetch + Web Crypto, no node:*) — alongside chat, in-app inbox, and webhook channels. 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 subscription = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
await client.mutation("registerDevice", { subscription });
// lunora/registerDevice.ts
import { mutation, v } from "@/lunora/_generated/server";

export const registerDevice = mutation.input({ subscription: v.any() }).mutation(async ({ ctx, args: { subscription } }) => {
    await ctx.push.register({ subscription, userId: ctx.auth?.userId });
});

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 (HTTP 404/410, FCM UNREGISTERED):

// 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:

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

// consumer (lunora/queues.ts)
for (const message of batch.messages) await runPushBroadcastJob(ctx.push, message.body);

See also