Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## [Unreleased]

### Added
- **Notification state can arrive over a socket instead of being polled for.** `Notify.startRealtime(channel: ...)` subscribes to the notifiable's private broadcast channel and applies each `notification.created` frame straight to the stream, so a new notification shows up when the server sends it rather than up to 30 seconds later. The frame carries the whole row in the same shape `GET /notifications` returns, so no HTTP follows it.
- `Notify.stopRealtime()`, `Notify.isRealtime` and `Notify.isPolling`.

### Changed
- **`startPolling()` is a no-op while realtime is live.** A consumer keeps wiring it to auth state and does not have to know whether a socket happens to be up: with one, the 30-second timer is waste on top of a connection that already delivers every row; without one, nothing changes. `stopRealtime()` or a dropped connection restores the timer.
- **`magic` constraint bumped to `^0.0.6`.** `Echo.connection` (the public driver accessor) is the floor for the realtime path: without it there is no way to tell an already-open connection from a closed one, and magic's Reverb driver opens a SECOND WebSocket on a redundant `connect()` instead of refusing it. A `0.0.z` caret pins the patch digit, so `^0.0.5` resolved exactly 0.0.5, which has no such accessor.

### Fixed
- **A socket frame that arrived while `fetchNotifications()` was in flight was clobbered by the read.** The frame prepended to the cached list and the server's list was then assigned over the top, so the notification vanished until something fetched again. The window is small and entirely real, because `startRealtime()` subscribes and THEN fetches, which is the exact moment a backlog is most likely to be publishing. Frames received during a read are now merged back on top, keyed by id.
- **`startRealtime()`'s idempotence key now includes the event name.** Keyed on the channel alone, a caller that changed the event for the same channel hit the early return, so the manager silently kept handling the old event name and delivered nothing, with no error.

### Notes
- Realtime is opt-in and degrades in both directions. `startRealtime()` returns `false` and changes nothing when no broadcast driver is configured (a `BROADCAST_CONNECTION=null` deployment), and a socket that drops falls back to polling until it returns, at which point the fallback is dropped and the list is refetched once to cover what Reverb cannot replay.
- The channel name is the caller's to supply (`App.Models.User.{id}` by Laravel's default): this package has no user model and cannot know whose notifications it is receiving. See `doc/basics/laravel-backend-setup.md` for the server half.

## [0.0.2] - 2026-07-26

### Changed
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Managing notifications in Flutter means juggling multiple channels — database
| :bell: | **Multi-channel** | Database, Push, and Mail channels through one API |
| :iphone: | **OneSignal Push** | iOS, Android, and Web push via `onesignal_flutter` |
| :arrows_counterclockwise: | **Real-time Polling** | Background polling with pause/resume/stop lifecycle |
| :satellite: | **Socket Delivery** | Take notification state from a broadcast channel instead, with polling as the fallback |
| :dart: | **User Preferences** | Global and per-type channel preference management |
| :hammer_and_wrench: | **CLI Tools** | Interactive install, configure, doctor, test, and more |
| :gear: | **Config-Driven** | All settings in one Dart config file via `ConfigRepository` |
Expand Down Expand Up @@ -134,6 +135,10 @@ import 'package:magic_notifications/magic_notifications.dart';
Future<void> onLoginSuccess(User user) async {
await Notify.requestPushPermission();
await Notify.initializePush('user_${user.id}');

// Prefer the socket; startPolling() is the fallback and no-ops when the
// socket is live, so both calls are safe in either order.
await Notify.startRealtime(channel: 'App.Models.User.${user.id}');
Notify.startPolling();
}
```
Expand All @@ -155,11 +160,39 @@ NotificationDropdownWithStream(

```dart
Future<void> onLogout() async {
Notify.stopRealtime();
Notify.stopPolling();
await Notify.logoutPush();
}
```

### Receive Notifications Over a Socket

`Notify.startRealtime()` subscribes to the notifiable's private broadcast channel
and applies each `notification.created` frame directly to the stream, so a new
notification appears when the server publishes it instead of up to one polling
interval later. It returns `false` and changes nothing when the app has no
broadcast driver configured, which is what keeps `startPolling()` meaningful on a
deployment without a socket.

```dart
final bool live = await Notify.startRealtime(
channel: 'App.Models.User.${user.id}',
);
```

Behaviour worth knowing:

- The existing list is fetched ONCE on start. A socket only carries what happens
next, so the rows that already exist still have to be read.
- A dropped connection falls back to polling; a reconnect drops the fallback and
refetches once, because Reverb has no replay.
- A redelivered id replaces the held row rather than appending a duplicate.

The server half (the `broadcast` channel on the notification, the event name, and
the payload shape) is in
[doc/basics/laravel-backend-setup.md](doc/basics/laravel-backend-setup.md).

---

## CLI Tools
Expand Down
84 changes: 84 additions & 0 deletions doc/architecture/notification-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- <a name="toc-push-driver"></a>[Push Driver Setup](#push-driver)
- <a name="toc-send-flow"></a>[Send Dispatch Flow](#send-flow)
- <a name="toc-polling"></a>[Polling Orchestration](#polling)
- <a name="toc-realtime"></a>[Realtime Delivery](#realtime)
- <a name="toc-streams"></a>[Stream Management](#streams)
- <a name="toc-optimistic"></a>[Optimistic Updates with Rollback](#optimistic)

Expand Down Expand Up @@ -174,6 +175,89 @@ poller.start();

---

## <a name="realtime"></a>Realtime Delivery

Polling is the fallback, not the only path. When the app has a broadcast driver,
the manager can take notification state off the socket instead:

```dart
final bool live = await Notify.startRealtime(
channel: 'App.Models.User.$userId',
);
```

`startRealtime()` does five things in order, and each one answers a specific
failure:

1. **Refuses when there is no driver.** It reads `broadcasting.default` and
returns `false` for `null`, empty, or the literal `'null'` driver. It does NOT
try to subscribe and see what happens: the null driver accepts a subscription
and silently delivers nothing, so an attempt-based probe would report success
on the one configuration that cannot work, silence the poller, and leave the
bell permanently empty.
2. **Connects only if nothing is connected.** `Echo.connect()` is not idempotent
in magic's Reverb driver: it assigns a fresh channel without closing the
previous one, so a redundant call opens a second WebSocket and leaks the first.
This is why `magic ^0.0.6` is the floor; `Echo.connection` is the accessor that
makes the check possible.
3. **Listens for `notification.created` exactly once.** A second `listen()` for
one event name REPLACES the earlier handler rather than adding to it, so
registering the same event anywhere else would silently drop this one.
4. **Stops the poller.** The socket now covers it.
5. **Fetches the existing list once.** A socket carries only what happens next.

### The frame is the state

A `notification.created` frame carries the whole row in the same shape
`GET /notifications` returns, so it is decoded and applied rather than treated as
a signal to fetch:

```dart
void _applyRealtimeFrame(BroadcastEvent event) {
final incoming = DatabaseNotification.fromMap(event.data);
_notifications = [
incoming,
..._notifications.where((n) => n.id != incoming.id),
];
_notificationController.add(_notifications);
}
```

Newest first, keyed by id, so a redelivery replaces the held row instead of
appending a duplicate the bell would count twice. A payload the decoder cannot
read is logged and dropped: it must not throw into the driver's listener, and it
must not clear what is already held, because a backend one version ahead is a
reason to miss one row and not to empty the list.

### Both directions of degradation

`connectionState` is watched (and `onReconnect` deliberately is not, since a
reconnect necessarily transitions the state to `connected` and listening to both
would fetch the same list twice for one event):

| Transition | What happens |
|------------|--------------|
| anything but `connected` | the poller is armed as a stand-in; the subscription stays, because realtime is still the intent |
| `connected` | the poller is dropped and the list is refetched once, because Reverb has no replay |

Without the first row, a socket that never comes back is a bell that never
updates again.

### Lifecycle hooks

| App Event | Call |
|-----------|------|
| User logged in | `Notify.startRealtime(channel: ...)` then `Notify.startPolling()` |
| User switched account | `Notify.startRealtime(channel: ...)` with the new channel |
| User logged out | `Notify.stopRealtime()` then `Notify.stopPolling()` |

Both calls on the login row are safe in either order and are idempotent per
channel, so wiring them to an auth-state listener that fires on every login,
logout and restore is the intended shape. `startPolling()` is a no-op while
realtime is live, so a consumer never has to branch on whether a socket is up.

---

## <a name="streams"></a>Stream Management

Database notifications are distributed via a broadcast `StreamController`:
Expand Down
99 changes: 99 additions & 0 deletions doc/basics/laravel-backend-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- <a name="toc-api-controllers"></a>[API Controllers](#api-controllers)
- <a name="toc-api-routes"></a>[API Routes](#api-routes)
- <a name="toc-onesignal-push"></a>[OneSignal Push Integration](#onesignal-push)
- <a name="toc-broadcast"></a>[Socket Delivery (Broadcast)](#broadcast)
- <a name="toc-sending"></a>[Sending Notifications](#sending)
- <a name="toc-api-contract"></a>[API Contract Reference](#api-contract)
- <a name="toc-testing"></a>[Testing](#testing)
Expand Down Expand Up @@ -458,6 +459,104 @@ public function routeNotificationForOneSignal(): array

---

## <a name="broadcast"></a>Socket Delivery (Broadcast)

The client can take notification state off a socket instead of polling for it
(`Notify.startRealtime`). That needs three things on this side: the `broadcast`
channel on the notification, a payload the client's decoder can read, and channel
authorisation.

### 1. Add the channel and shape the frame

```php
use Illuminate\Notifications\Messages\BroadcastMessage;

class MonitorDownNotification extends Notification
{
public function via(object $notifiable): array
{
// Alongside `database`, never instead of it: the socket is delivery, the
// row is the record. A client that was offline reads the row over the API.
return ['database', 'broadcast'];
}

/**
* The wire event name.
*
* Laravel's default is the fully-qualified
* `Illuminate\Notifications\Events\BroadcastNotificationCreated`. Magic's
* Reverb channel matches a listener by EXACT string, so the client would have
* to hardcode a framework internal; this short name is the contract instead.
*/
public function broadcastAs(): string
{
return 'notification.created';
}

/**
* The payload, in the SAME shape `GET /notifications` returns a row.
*
* This matters more than it looks. Laravel's default broadcast payload
* FLATTENS the notification data to the top level and adds `id` and `type`,
* while `DatabaseNotification.fromMap` on the client reads `data.title`,
* `data.body` and `data.action_url` from a NESTED `data` key, so the default
* decodes to nothing useful. Building the frame from `toArray()` keeps one
* serializer behind both the API and the socket, so the two cannot drift.
*/
public function toBroadcast(object $notifiable): BroadcastMessage
{
return new BroadcastMessage([
'id' => $this->id,
'type' => static::class,
'data' => $this->toArray($notifiable),
'created_at' => now()->toIso8601String(),
'read_at' => null,
]);
}
}
```

### 2. Authorise the channel

The default channel for a `Notifiable` is its class name with dots plus its key,
so `App\Models\User` id `42` publishes on `App.Models.User.42`. Authorise it in
`routes/channels.php`, scoped to the one user:

```php
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('App.Models.User.{userId}', function (User $user, string $userId): bool {
// A notification is personal. Comparing as strings keeps a UUID key working.
return (string) $user->id === (string) $userId;
}, ['guards' => ['sanctum']]);
```

The `guards` option matters for an API client: the channel-auth request arrives
with a bearer token, not a session cookie, so the default `web` guard would deny
every subscription.

Override `receivesBroadcastNotificationsOn()` on the notifiable if you want a
different name; the client is told the channel by its caller, so any name works as
long as both sides agree.

### 3. Point the client at it

```dart
await Notify.startRealtime(channel: 'App.Models.User.${user.id}');
```

Nothing else changes. `Notify.startPolling()` stays wired to auth state and
becomes a no-op while the socket is live, so a deployment with
`BROADCAST_CONNECTION=null` keeps polling and needs no client change at all.

> **The row is still the record.** Keep `database` in `via()`. The socket delivers
> to whoever is connected; a client that was closed when the notification fired
> learns about it from the API on its next start, which is the fetch
> `startRealtime()` does once.

---

## <a name="sending"></a>Sending Notifications

### From Controller
Expand Down
43 changes: 43 additions & 0 deletions lib/src/facades/notify.dart
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,47 @@ class Notify {
static void resumePolling() {
manager.resumePolling();
}

/// Whether the periodic poller is currently armed.
static bool get isPolling => manager.isPolling;

// ========================================
// Realtime
// ========================================

/// Receive notification state over the app's broadcast socket instead of
/// polling for it.
///
/// [channel] is the private channel the backend publishes the user's rows on,
/// `App.Models.User.{id}` by Laravel's default. Wire it to auth state next to
/// [startPolling], which becomes a no-op while this is live:
///
/// ```dart
/// if (Auth.check()) {
/// await Notify.startRealtime(channel: 'App.Models.User.' + User.current.id);
/// Notify.startPolling(); // the fallback, if there is no socket
/// } else {
/// Notify.stopRealtime();
/// Notify.stopPolling();
/// }
/// ```
///
/// Returns false when the app has no broadcast driver configured, so the caller
/// above keeps polling. See [NotificationManager.startRealtime].
static Future<bool> startRealtime({
String? channel,
String event = NotificationManager.realtimeEvent,
}) {
return manager.startRealtime(channel: channel, event: event);
}

/// Stop receiving notification state over the socket.
///
/// Call on logout, next to [stopPolling].
static void stopRealtime() {
manager.stopRealtime();
}

/// Whether notification state is currently arriving over a socket.
static bool get isRealtime => manager.isRealtime;
}
Loading