Skip to content

feat(notifications): let realtime broadcasts wake a device and supersede themselves - #9

Merged
3m1n3nc3 merged 4 commits into
mainfrom
feat/notifications/realtime-delivery-and-supersession
Sep 16, 2026
Merged

3m1n3nc3 merged 4 commits into
mainfrom
feat/notifications/realtime-delivery-and-supersession

Conversation

@3m1n3nc3

Copy link
Copy Markdown
Member

Why

The Firebase driver sent data: { event, payload } and nothing else — no android.priority, no TTL, no collapse key, no APNs headers. An FCM data message defaults to normal priority, and Doze and App Standby may hold a normal-priority message until the next maintenance window, which is exactly the state a device is in when something time-critical needs to reach it.

It also could not be worked around downstream. The hand-rolled FirebaseMessaging slice declared send({ topic, data }) with no index signature, and RealtimeDriver.broadcast was fixed at three parameters, so there was nowhere for a caller to pass a priority.

What

Delivery options. .priority(), .ttl() and .collapseKey() on the realtime builder, reaching the driver through a new optional delivery argument. Each hint converts to the unit its platform expects — Android TTL in milliseconds, apns-expiration as an absolute epoch second, Web Push in seconds. Defaults live under drivers.realtime.delivery or per transport; raw android / apns / webpush blocks pass through for anything unmapped. Pusher accepts and ignores them.

Supersession. .tag() gives a notification a stable identity so a later one replaces it in place; .retract() withdraws one outright. @arkstack/realtime gains supersede(), applied by the React and Vue bindings and exported for hand-rolled lists.

Custom transports. driverFactory supplies your own RealtimeDriver, mirroring the client's existing transportFactory, and broadcast() now accepts non-notification payloads.

Two deliberate calls

Priority does not reach APNs by default. A data-only message becomes a background push on iOS, and Apple requires those to be priority 5 or 1 — mapping high to 10 would send APNs something documented to be rejected, turning throttled-but-working delivery into an error. It is derived only once the caller declares a visible push through the apns escape hatch. Ringing on iOS needs PushKit, which FCM cannot address at all; driverFactory is the supported way to bring that yourself.

tag is not wired to collapseKey. They look alike but have different budgets: a tag is an unbounded client-side identity, while FCM keeps only four collapse keys per device and silently evicts the rest. Deriving one per tag would starve that budget. Set both when you want to supersede both what is queued and what is displayed.

Breaking?

No — minor. Every addition is optional, and an unhinted broadcast produces a byte-identical FCM message. I probed a driver written against the old three-parameter contract with the narrow payload type; it still compiles, since TypeScript compares method parameters bivariantly.

Two notes for the release:

  • drivers.realtime.transport went required → optional, since naming a built-in is meaningless alongside driverFactory. Writing a config is unaffected; reading that property now yields | undefined.
  • Ship @arkstack/realtime before sending a first retraction. A client that predates this ignores retracted and would render a retraction as an empty notification. This is also the argument for superseding with replacement content rather than a bare retraction — a replacement degrades correctly on an old client.

Verification

76 tests pass across both packages (40 new), pnpm lint clean, both packages and the docs site build.

…ede themselves

The Firebase driver sent `data: { event, payload }` and nothing else. With no
`android.priority`, a data message goes out at normal priority, which Doze and
App Standby are free to hold until the next maintenance window -- exactly the
state a device is in when something time-critical needs to reach it. The typed
messaging slice also made this unreachable from outside: there was no parameter
to pass a priority through, so it could not be worked around downstream.

Delivery options
  `.priority()`, `.ttl()` and `.collapseKey()` on the realtime builder, carried
  to the driver through a new optional `delivery` argument on `RealtimeDriver`.
  Each hint converts to the unit its platform expects -- Android TTL in
  milliseconds, `apns-expiration` as an absolute epoch second, Web Push in
  seconds. Defaults can be set under `drivers.realtime.delivery` or per
  transport, and raw `android` / `apns` / `webpush` blocks pass through for
  anything unmapped. Pusher accepts and ignores them; it holds an open
  connection, so there is no dozing radio to wake.

  Priority reaches APNs only once the message is a visible push, which the
  caller declares through the `apns` escape hatch. Apple requires a background
  push -- what a data-only message becomes on iOS -- to be priority 5 or 1, so
  deriving 10 there would send APNs something it is documented to reject.

Supersession
  `.tag()` gives a notification a stable identity so a later one replaces it
  rather than stacking beside it, and `.retract()` withdraws one outright.
  Retractions are never persisted. `tag` is deliberately not wired to
  `collapseKey`: a tag is an unbounded client-side identity, while FCM keeps
  only four collapse keys per device and silently evicts the rest.

  `@arkstack/realtime` gains `supersede()`, applied by the React and Vue
  bindings and exported for hand-rolled lists.

Custom transports
  `driverFactory` supplies a `RealtimeDriver` of your own, mirroring the
  client's existing `transportFactory`; `broadcast()` now accepts payloads that
  are not notifications. Together these let an application reach a transport
  this package does not ship -- APNs or PushKit, say -- without forking it.

Additive throughout: an unhinted broadcast produces a byte-identical message,
and a driver written against the old three-parameter contract still compiles.
…uctor

- notifications: add `CUserNotification`, a constructor type for the abstract
  `UserNotification` model, and resolve `getModel` against it.
- jobs, h3 template: import ordering, method spacing, and a narrower
  `H3Middleware` import path.
@3m1n3nc3
3m1n3nc3 force-pushed the feat/notifications/realtime-delivery-and-supersession branch from eb36d78 to 6c467de Compare September 16, 2026 12:59
@codecov

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.69231% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/auth/src/Auth.ts 60.00% 12 Missing ⚠️
packages/auth/src/AuthSession.ts 33.33% 2 Missing ⚠️
...ckages/notifications/src/UserNotificationCenter.ts 0.00% 1 Missing ⚠️
...ons/src/drivers/realtime/FirebaseRealtimeDriver.ts 97.56% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@3m1n3nc3
3m1n3nc3 merged commit 4da325b into main Sep 16, 2026
4 checks passed
@3m1n3nc3
3m1n3nc3 deleted the feat/notifications/realtime-delivery-and-supersession branch September 16, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant