maravilla-push
Maravilla Web Push
Browser-side opt-in via the standard Web Push API; server-side fan-out + scheduling via platform.push. The platform handles VAPID, subscription storage, retry, and idempotent scheduling.
platform.push is optional — it's only present when Web Push is enabled in project settings. The dev-server fallback may leave it undefined; check before use.
Browser side
Register a subscription
import { registerPush } from '@maravilla-labs/platform/push';
const { subscriptionId } = await registerPush({
topics: ['waitlist', 'invite:abc:rsvp'],
userId: user?.id ?? null,
visitorId: anonId, // optional — for anonymous opt-ins
swPath: '/sw.js', // your service worker (default '/sw.js')
});
This:
- Asks the browser for
Notification.permission - Creates a
PushSubscriptionwith the project's VAPID public key - POSTs the subscription + topics to the platform
- Returns the platform's
subscriptionId— store it locally so you canunregisterPushlater
Persist subscriptionId keyed by topics (or by user) in localStorage so you can offer a "turn off notifications" toggle:
function storageKey(topics: string[]) {
return `push:${[...topics].sort().join(',')}`;
}
localStorage.setItem(storageKey(topics), JSON.stringify({ subscriptionId, createdAt: Date.now() }));
Unregister
import { unregisterPush } from '@maravilla-labs/platform/push';
await unregisterPush(subscriptionId);
The server may also prune dead subscriptions automatically on send if the push service returns gone — unregisterPush 404s are fine to swallow:
try { await unregisterPush(id); } catch (err) { /* sub already gone — log and move on */ }
Service worker
You provide the service worker. Minimal handler:
// public/sw.js
self.addEventListener('push', (event) => {
const payload = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: payload.icon,
badge: payload.badge,
tag: payload.tag,
data: payload,
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url ?? '/';
event.waitUntil(self.clients.openWindow(url));
});
Server side
send(target, notification) — fan-out
const report = await platform.push!.send(
{ topic: 'waitlist' }, // PushTarget
{
title: 'You\'re in',
body: 'Your account is ready',
url: '/dashboard',
},
);
// report: { attempted, succeeded, gone, failed, errors? }
Blocks until every device has been tried. Use sendBackground instead when the request handler should return immediately:
await platform.push!.sendBackground(target, notification);
PushTarget — narrowing
All specified conditions must match for a subscription to receive the push:
{ topic: 'waitlist' } // every sub tagged with this topic
{ userId: 'usr_42' } // every device for one user
{ userId: 'usr_42', topic: 'invite:abc:rsvp' } // narrow to a specific user×topic
{ userIds: ['usr_1', 'usr_2'] } // batch
{ topics: ['waitlist', 'beta'] } // OR across topics
{ visitorId: 'anon_abc', onlyActive: true } // anonymous + active only
NotificationPayload
{
title: string, // required
body?: string,
icon?: string,
badge?: string,
image?: string,
tag?: string, // browsers dedupe on this
url?: string, // navigated to on click
data?: Record<string, unknown>, // arbitrary JSON for the SW
ttl?: number, // seconds the push service holds while offline
urgency?: 'very-low' | 'low' | 'normal' | 'high',
}
Scheduling
One-shot reminders
await platform.push!.schedule(
{ topic: `invite:${invite.id}` },
{ title: invite.title, body: 'Your event is in one hour' },
{
at: offsetBefore(invite.event_date, '1h'), // Date or ISO-8601 string
key: `invite:${invite.id}:reminder-1h`, // idempotency key
},
);
Idempotent updates
key is project-scoped. Re-calling schedule with the same key atomically replaces the prior pending job. Safe to call on every save of an invite whose event date may move:
// On every invite save:
await platform.push!.schedule(target, notification, {
at: offsetBefore(invite.event_date, '1h'),
key: `invite:${invite.id}:reminder-1h`,
});
Recurring digests
everySeconds re-queues the job after every successful send:
await platform.push!.schedule(
{ userId },
{ title: 'Your daily digest', url: '/digest' },
{
at: nextRunAt,
key: `digest:${userId}`,
everySeconds: 86_400, // every 24h
},
);
// Stop the loop
await platform.push!.cancelScheduled(`digest:${userId}`);
Inspect / cancel
// Single
const job = await platform.push!.getScheduled(`digest:${userId}`);
// job: ScheduledJob | null
// List
const jobs = await platform.push!.listScheduled({ status: 'pending', limit: 50 });
// Stats
const stats = await platform.push!.queueStats();
// { pending, running, succeeded, failed }
// Cancel by key (idempotent)
const { canceled } = await platform.push!.cancelScheduled(key);
Subscription admin
// Filter
const subs = await platform.push!.list({
topic: 'waitlist',
onlyActive: true,
limit: 100,
});
// Aggregate counts
const counts = await platform.push!.counts();
// { total, byTopic: [['waitlist', 1234], ...], byProvider: [['web-push', 5000], ...] }
// Remove
await platform.push!.unsubscribe(subscriptionId);
await platform.push!.unsubscribeByEndpoint(endpoint);
VAPID config
const cfg = await platform.push!.getVapidConfig();
// { vapidPublic, contactEmail, updatedAt }
// Rotate — every existing subscription stops working silently!
const newCfg = await platform.push!.rotateVapidKeys();
Rotating VAPID invalidates every subscription (browsers bind subs to the key they saw at subscribe time). Confirm with users before rotating; usually only done in response to a known key compromise.
Patterns
Per-invite RSVP reminder, idempotent
async function rescheduleReminder(invite) {
const key = `invite:${invite.id}:reminder-1h`;
if (!invite.event_date) {
await platform.push.cancelScheduled(key);
return;
}
await platform.push.schedule(
{ topic: `invite:${invite.id}` },
{ title: invite.title, body: 'Your event is in one hour' },
{ at: new Date(invite.event_date - 3600_000), key, maxAttempts: 5 },
);
}
Call rescheduleReminder(invite) on every save — the idempotency key replaces the pending job atomically.
Anonymous → user upgrade
When an anonymous visitor signs up, re-tag their subs:
// onAuth({op:'registered'}) handler
const subs = await platform.push.list({ visitorId: ctx.visitorId, onlyActive: true });
for (const sub of subs) {
// The platform doesn't expose direct re-tag — easiest is unsubscribe + browser re-registers with userId
}
In practice the browser's registerPush is idempotent on endpoint, so just call it again with the new userId:
await registerPush({ topics, userId: newUserId });
Pitfalls
platform.pushis optional. Always checkif (platform.push) { ... }or useplatform.push!only when you've gated the codepath behind a config check.- VAPID rotation breaks every subscription. Don't rotate casually.
- Service worker scope. The SW must be served from a path that covers your registration page;
/sw.jsworks for top-level registration. - iOS PWA quirks. Web Push on iOS requires the user to install the PWA first (Add to Home Screen). Plan UI accordingly.
tagfor de-dup. Notifications sharing atagreplace each other in the OS shade; pick distinct tags for genuinely different messages.
Related skills
- maravilla-events —
onAuth,onSchedule,onDbtriggers that often kick off a push - maravilla-realtime — for delivery to online clients (don't push when SSE is enough)
- maravilla-workflows — multi-step reminder pipelines
Full reference: https://www.maravilla.cloud/llms-full.txt.
More from maravilla-labs/maravilla-cli
maravilla-auth
Maravilla Cloud authentication. Use whenever wiring login/register/session, OAuth callbacks, resource policies, or hitting `platform.auth.*` APIs. Critical: the 3-step request-scoped contract (validate → setCurrentUser → can) — skipping any step silently breaks Layer-2 policies and owner-scoped reads return empty with no error.
12maravilla-events
Maravilla Cloud event handlers — files in `events/*.ts` auto-discovered by the framework adapter. Use to react to data changes (`onKvChange`, `onDb`), auth lifecycle (`onAuth`), schedule (`onSchedule`), queue messages (`onQueue`), realtime publishes (`onChannel`), deploy phases (`onDeploy`), object storage (`onStorage`), or arbitrary REN events (`defineEvent`). Run inside the Maravilla runtime with full platform access via `ctx`.
12maravilla-workflows
Maravilla Cloud durable workflows — replay-based, multi-step processes that survive restarts. Use whenever you need sleeps spanning minutes/hours/days, multi-step pipelines where each step's output feeds the next, waiting for external events, or strict step-history audit. `defineWorkflow` from `@maravilla-labs/functions/workflows/runtime` with `step.run`, `step.sleep`, `step.sleepUntil`, `step.waitForEvent`, `step.invoke`.
12maravilla-media-transforms
Async media + document derivations via `platform.media.transforms` and the declarative `transforms` block in `maravilla.config.ts`. Media: transcode video, thumbnail extraction, image resize/variants, OCR. Documents (.docx/.odt/.pptx/.xlsx/...): convert to PDF, render page thumbnails, generic format conversion, Markdown extraction (RAG-ready), single-file HTML with inlined images, image-replacement templating ({{TAG}} swap + named-object swap), QR-code injection. Use when ingesting user uploads that need normalised renditions, generating contracts/invoices from templates, or extracting structured content for LLMs. Critical: derived keys are content-addressed — `keyFor(srcKey, spec)` is known up front, before the worker starts, so clients can render placeholder UI without round-trips. Declarative config is the default; imperative `transforms.*` calls are for one-offs.
12maravilla-db
Maravilla Cloud document database — MongoDB-style queries, secondary indexes, and vector search. Use for structured app data, multi-field queries, sorting, semantic search via `findSimilar` / hybrid `find` with `options.vector`. Exposed as `platform.env.DB`. Vector indexes support int8/bit quantization, matryoshka, and multi-vector (ColBERT) out of the box.
11maravilla-config
The `maravilla.config.ts` declarative project file. Use whenever creating or modifying auth resources, groups, relations, registration fields, OAuth providers, password/session policy, branding, database indexes, or media transforms. Reconciled into delivery on every deploy — partial adoption is supported (omit a section to leave it untouched).
11