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.