Push notifications
Declare push rules in sp00ky.yml, subscribe browsers with db.webPush, and show notifications from your service worker. No push server, no keys to manage.
Push notifications that reach a closed tab, an installed app, or a phone in a pocket. You declare rules in sp00ky.yml (“when a row of message is created, push to the user in its recipient field”), browsers subscribe through db.webPush, and the platform encrypts and delivers. There is no push server to run, no Web Push library to wire up, and no key pair to manage: the deployment derives its VAPID key from the project’s auth secret.
Concepts
There are three ways a push gets sent, and two shapes it can have.
| Kind | Sent by | Typical use |
|---|---|---|
| Rule | the platform, when a synced row matches a push: rule | new message, mention, job finished |
| Direct message | root SurrealQL (a backend, a DEFINE EVENT, spky push send) | maintenance notice, digest |
| Self-push | the signed-in user, through db.webPush.notify() / test() | reminders, “send me a test” |
| Shape | What it carries | Who renders it |
|---|---|---|
| Content push | a rendered notification (title, body, url, actions, …) | the service worker shows it as is |
| Nudge | no notification, only the row id, rule and optional data | your service worker, typically from its own live data |
A rule with a notification: block sends content pushes. Leave the block out and it sends nudges. Content pushes are simple and work with any service worker; nudges keep the payload free of user content and let the notification always show the latest state.
The server cannot know whether a user has the app open: a matching row pushes to every subscribed device of the recipient. The service worker decides on the device: while a page of the app is visible it hands the push to that page instead of showing a notification.
5-minute setup
1. Add a rule
spky lint validates the block; spky dev and spky deploy store it (spky push sync stores it without a deploy). Every string under notification, topic and data is a template: {{ field }} reads the row, with: queries run as root with $row bound, and filters (truncate(n), key, default(x), upper, date(format), …) shape the output.
Recipients come from row fields (to: recipient, to: [owner, assignee]) or from a root query (to: { query: "SELECT VALUE user FROM member WHERE club = $row.club" }). Conditions (when:), once: per state change, maxAge: and urgency: / ttl: are available on every rule; see Web Push in the sp00ky.yml reference for every option.
2. Add a service worker
That is the whole worker for content pushes: it shows each notification with its tag, icon and actions, opens (or focuses) the notification’s url on click, suppresses pushes while the app is visible, and keeps the session the page bridges over.
3. Subscribe from a button
subscribe() asks for permission (when it is still default), creates the browser’s push subscription with the deployment’s key, and registers it for the signed-in user. Outside Solid, the same object is client.webPush on a Sp00kyClient.
4. Send a test push
Click the “Send me a test” button, or send one from the terminal:
Bundling the worker
The worker imports from npm, so it has to be bundled into one file served from your origin (a worker only controls pages at or below its own path, so serve it from /). With Vite, vite-plugin-pwa’s injectManifest strategy builds src/sw.ts for you and registers it:
If the app registers its worker some other way, nothing else is needed: db.webPush finds the page’s registration. If nothing registers one, set webPush.serviceWorkerUrl and the client registers it when it first needs it.
@spooky-sync/core/sw and @spooky-sync/core/live contain no WebAssembly,
no logger and no DOM access. The build checks that on every release. /sw on
its own is about 3.6 kB gzipped; with /live (which brings the pure-JS
surrealdb SDK) the worker is about 30 kB gzipped.
Rendering from live data
A nudge carries no text, so the worker decides what to show. The live option does it for you: on each nudge it opens a live feed with the bridged session, reads your query, shows one notification per visible row under its own tag, closes the notifications of rows that are no longer visible (read on another device, deleted), and closes the feed.
The feed speaks the same protocol as the full client (it registers the query, reads its membership, then follows the user’s _00_list_ref table with LIVE ... FETCH out), so permissions apply exactly as they do in the app. It needs the session: the page posts it to the worker whenever the token changes while notifications are allowed (webPush.bridge, on by default). An impersonation token is never posted.
When the feed cannot load in time (no session yet, offline, 8 s budget by default via live.timeoutMs) the worker shows the placeholder, since the browser requires something on screen.
A nudge whose only effect is closing notifications (the row was read on another device) shows nothing, which is what the user expects. Chrome tolerates occasional pushes that show nothing; if your origin sends many of them and Chrome starts showing its own “updated in the background” notice, set live.silentClose: false (the placeholder is shown instead), or narrow the rule with when: so only the changes worth a notification push.
createLiveFeed works anywhere with a WebSocket, including a dedicated worker or Node:
Signed out, a feed’s views answer only to the feed: each registration carries a
secret minted per feed (ownerSecret), and the server refuses anyone else’s
registration of that view. Two feeds that share views by passing the same
sessionId must pass the same ownerSecret too.
With sync.queryAllowlist on, the worker’s query goes through the same
allowlist as the app’s: write it with the query builder, in a file the
generator scans, so its shape is collected.
If you would rather render nudges yourself, pass onNudge(payload, ctx) (call ctx.show(...) at least once) or render(payload) (return a notification spec). onPush sees every push first and can take over completely by returning true.
Per-device rule filters
A user may want direct messages on their phone but not every mention on the work laptop. Each device carries an optional list of rule names; direct messages and self-pushes always pass.
Self-pushes and scheduled reminders
A signed-in user can push to their own devices, now or later. Messages with a future sendAt wait on the server until they are due, so the reminder arrives even if every tab is closed.
Backends push to anyone by creating a _00_push_message as root:
When the app is open
With suppressWhenVisible (the default) a push that arrives while a page of the app is visible is not shown; the worker posts { type: 'sp00ky:push', payload } to the visible pages instead:
A notification click focuses an open window and loads the notification’s url in it, or opens a new window. Pass clickMode: 'message' to installPushHandlers to route in-app instead (the page receives sp00ky:navigate), or onClick to handle clicks entirely yourself.
Sign-out and shared devices
On signOut(), before the token is dropped, the client removes this device’s subscription for the user and tells the worker to forget the session and close every notification it shows. Both are best-effort with a short timeout, so sign-out never waits on the network. The next user who enables notifications on the same browser gets their own subscription; the platform delivers an endpoint only to its most recent owner.
While impersonating, db.webPush refuses to subscribe or push, and an operator’s browser is never attached to someone else’s notifications.
After sign-in, webPush.autoSync (on by default) runs db.webPush.sync(): when the user subscribed on this browser before and permission is still granted, it repairs a browser subscription that went away, one made with a rotated key, or a server row that was removed. It never prompts. Set autoResubscribe: true to also subscribe users who never subscribed here but whose browser already allows notifications.
Phones (iOS, Android)
The same rules reach native apps: configure push.apns / push.fcm and add the spooky_push Flutter plugin. A rule’s notification shows on phones too, a native: block overrides it there, and platforms: limits a rule to some kinds of device. See Native push.
iOS and iPadOS (web apps)
Safari on iOS and iPadOS 16.4+ supports Web Push only for web apps added to the Home Screen (your manifest needs display: "standalone"). In a Safari tab, db.webPush.support() returns { supported: false, reason: 'ios-not-installed' }: show a short “Add to Home Screen” hint instead of the button. Once installed and opened from the Home Screen, the same subscribe() works.
Asking for permission
- Ask in context, from a click. Browsers block or quietly deny prompts that are not triggered by a user gesture, and Chrome shows a muted prompt to sites that ask on page load.
subscribe()requests permission first thing, so call it directly from the click handler. - Explain first. Show why notifications are useful (“Get a message when someone replies”) before the browser prompt appears.
- A denial is final until the user changes it in the browser settings (
permission-denied). Do not keep asking; show how to re-enable instead. - Offer granular control. Rule filters and a device list are cheaper than losing the permission entirely.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| support() | WebPushSupport | - | { supported, permission, reason? }. Reasons: no-window, insecure-context, no-service-worker, no-push-manager, no-notification, ios-not-installed. |
| info() | Promise<WebPushInfo> | - | Whether web push is enabled on the deployment, its public key and key id, and the providers it reaches (web, apns, fcm). Cached. |
| subscribe(opts?) | Promise<WebPushDevice> | - | Permission, browser subscription and server registration. opts: registration, label, rules, meta, requestPermission. |
| unsubscribe(opts?) | Promise<number> | - | Remove this device (all: every device of the user). keepBrowserSubscription keeps the browser side. |
| sync(opts?) | Promise<WebPushSyncStatus> | - | Reconcile after sign-in. ok, resubscribed, registered, or why nothing happened. Never throws. |
| isSubscribed() | Promise<boolean> | - | This browser holds a subscription registered for the signed-in user. No network. |
| update(opts) | Promise<WebPushDevice | null> | - | Change label, rules (null resets to every rule) or meta of this device. |
| devices() | Promise<WebPushDevice[]> | - | The user's devices, native ones included (kind: web | apns | fcm, platform, appId, environment), this one marked with thisDevice. |
| notify(msg) | Promise<WebPushMessage> | - | Push to yourself: notification, data, topic, urgency, ttl, sendAt. |
| cancel(id) | Promise<boolean> | - | Cancel a scheduled self-push that is still pending. |
| test(opts?) | Promise<WebPushMessage> | - | A visible test notification to every device of the user. |
| bridge(registration?) | () => void | - | Post the session to the worker now and on every token change. Automatic with webPush.bridge. |
| onMessage(cb) | () => void | - | Messages from the worker: sp00ky:push, sp00ky:navigate, sp00ky:subscriptionchange. |
Errors are WebPushError with a code: unsupported, signed-out, impersonating, disabled, permission-denied, permission-default, no-registration, not-subscribed, subscribe-failed, server.
installPushHandlers(options) from @spooky-sync/core/sw:
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | { title, body?, icon? } | generic text | Shown when a push cannot be rendered (unknown payload version, nudge without a renderer, feed failure). |
| suppressWhenVisible | boolean | true | Post to visible pages instead of showing. |
| onPush(payload, ctx) | Promise<boolean | void> | - | Sees every push first; return true when you handled it. |
| onNudge(payload, ctx) | Promise<void> | - | Render content-free pushes yourself (ctx.show, ctx.bridged, ctx.visibleClients). |
| render(payload) | NotificationSpec | null | - | Custom rendering for content pushes and nudges; null keeps the default. |
| live | { feed, query, visible, render, timeoutMs? } | - | Render nudges from live data (see above). |
| onClick(event, payload, action) | Promise<boolean | void> | - | Return true to replace the default focus / open behaviour. |
| clickMode | 'navigate' | 'message' | 'navigate' | How a click reaches an open window. |
| resolveUrl(url, payload) | string | relative to the scope | Map a notification url to the one to open. |
| feed(session) | LiveFeed | - | Used on pushsubscriptionchange to register the new endpoint when live is not configured. |
getBridgedToken() returns the bridged session ({ token, userId, endpoint, namespace, database }) or null, for your own worker code.
The payload every push carries:
kind: "message" (direct and self-pushes) carries message (the _00_push_message id) instead of rule, table, id and op.
Troubleshooting
| Symptom | Check |
|---|---|
subscribe() throws disabled | No host with push enabled has started yet, or the deployment has no auth secret. spky push status shows whether the key is published. SPKY_PUSH=off turns push off. |
support() says insecure-context | Push needs HTTPS (or localhost). |
no-registration | No service worker is active for the page. Check the Application tab in DevTools, or set webPush.serviceWorkerUrl. |
| Nothing arrives | spky push devices user:abc lists the user’s devices with their last error. A device disabled with http_403 was made for an old key: the next sync() (on sign-in) replaces it. Rule filters on the device may exclude the rule. |
| Chrome shows “This site has been updated in the background” | The worker did not show a notification for a push. Keep a placeholder, and make sure onNudge always calls ctx.show(). |
| Notifications keep coming after sign-out | Only when sign-out could not reach the server (offline, or while impersonating). The row goes the next time the push service reports the endpoint gone, or on the next user’s subscribe on that browser. |
| The first push after subscribing arrives seconds late | Expected with Chrome: FCM answers 410 for a subscription made a few seconds earlier. The engine retries a subscription (re)made in the last 2 minutes (after 2 s, then 10 s) instead of deleting it; later pushes arrive in well under a second. |
| Live rendering always shows the placeholder | The worker has no session (sign in once with notifications allowed, webPush.bridge on), or the query takes longer than live.timeoutMs. |