diff --git a/src/docs/src/recipes/events-catch-up-on-notifications.md b/src/docs/src/recipes/events-catch-up-on-notifications.md new file mode 100644 index 000000000..1ecbc2f52 --- /dev/null +++ b/src/docs/src/recipes/events-catch-up-on-notifications.md @@ -0,0 +1,176 @@ +--- +title: Catch Up on Notifications +description: "Learn how to show Puter notifications in your app with Puter.js events, including the ones that arrived while your app was closed." +tags: [events] +order: 66 +--- + +
The Events API is in beta. Event shapes, limits, and behavior may change between releases.
+ +Puter posts notifications to a user's account when something needs their +attention, for example when a persistent subscription your app made for them +ends, or, for the app's owner, when its worker finishes deploying. With the +[Events API](/Events/) your app can show these as they arrive, and catch up on +the ones that came in while it was closed. Puter creates notifications itself. +An app can't post its own. + +This recipe builds on [Watch for Changes](/recipes/events-watch-for-changes/), +which covers what every subscription shares: gaps, stopping, reconnects and +cost. The examples call `showNotification(event)`, your own function that shows +one notification, such as a toast or a row in a panel. + +## Show New Notifications + +To show notifications as they arrive, subscribe to `notif:app-user` with +[`puter.events.onLocal()`](/Events/onLocal/): + +```js +const sub = await puter.events.onLocal('notif:app-user', ({ event }) => { + if (event.op === 'gap') return; + showNotification(event); +}); +``` + +A gap carries no notification, so this handler skips it. +[Catch Up, Then Keep Listening](#catch-up-then-keep-listening) handles gaps +properly. The fields to use are: + +- `id`: the notification's id. A notification has the same `id` whether it + arrives live or from `fetch()`, so use it to tell notifications apart. +- `type`: what kind of notification it is, such as `app.events.ended`. +- `notification.title`: the text to show. The other fields in `notification` + depend on the type. +- `ts`: when it was created, in milliseconds since the epoch. + +Don't use `seq` or `ts` to tell notifications apart. `seq` is only a position +within one page or one delivery, and two notifications can share a `ts`. + +## Which Notifications Your App Sees + +A `notif:` subject names one part of the user's notifications: + +- `notif:app-user` is about the user's use of your app. Today that is Puter + telling them that a [persistent subscription](/Events/onPersistent/) your app + made for them has ended or stopped delivering (`app.events.ended`). +- `notif:developer` is about your app itself, for the account that owns it: + your app's worker deploys (`app.worker.deployed`, `app.worker.deployFailed`) + and suspended event handlers (`app.events.suspended`). For any other user, + `onLocal()` rejects with `subject_does_not_exist` and `fetch()` returns an + empty page. +- `notif:account` holds account notifications, such as a file shared with the + user. Apps and websites never see these, and no permission changes that. + `onLocal()` rejects with `subject_does_not_exist` and `fetch()` returns an + empty page. + +Reading your app's own notifications needs no permission. Puter fills in your +[app ID](/Utils/appID/), so the subject never needs one, and another app's +notifications are never visible to yours. + +## Read What You Missed + +To read the notifications that arrived while your app was closed, call +[`puter.events.fetch()`](/Events/fetch/). It returns a page of notifications, +oldest first, in the same shape as live ones. Pass each page's `cursor` back as +`after` until a page comes back without one: + +```js +let after; +do { + const page = await puter.events.fetch({ subject: 'notif:app-user', after }); + page.items.forEach(showNotification); + after = page.cursor; +} while (after); +``` + +A page holds 50 notifications by default. Pass `limit` for up to 200, and a +larger `limit` is treated as 200. Without `after`, `fetch()` starts from the +oldest notification Puter still keeps. Puter deletes old notifications after a +while, so a user who was away longer than that starts from what is left. + +`fetch()` only reads. It doesn't mark anything as read, and it also returns +notifications the user already dismissed, so your app has to remember what it +has shown. The next section does that. + +Only `notif:` subjects can be fetched. A `kv:` or `fs:` subject rejects with +`fetch_unsupported_subject`, because nothing stores those changes. Read the +data itself again instead, as in +[Watch for Changes](/recipes/events-watch-for-changes/). + +## Catch Up, Then Keep Listening + +To show each notification once, across visits, combine the two. Subscribe, then +fetch from where the last visit stopped, and save the cursor with +[`puter.kv`](/KV/) so the next visit starts there: + +```js +const subject = 'notif:app-user'; +const saved = await puter.kv.get('notifications') ?? {}; +let after = saved.after; +const seen = new Set(saved.seen); + +function showOnce (event) { + if (seen.has(event.id)) return; + seen.add(event.id); + showNotification(event); +} + +async function save () { + await puter.kv.set('notifications', { after, seen: [...seen].slice(-200) }); +} + +async function catchUp () { + let page; + do { + page = await puter.events.fetch({ subject, after }); + page.items.forEach(showOnce); + after = page.cursor ?? after; + } while (page.cursor); + await save(); +} + +await puter.events.onLocal(subject, async ({ event }) => { + if (event.op === 'gap') return catchUp(); + showOnce(event); + await save(); +}); +await catchUp(); +``` + +Subscribe first, then fetch. A notification created while `catchUp()` runs then +reaches the handler. If you fetch first, one created between the fetch and the +subscription stays hidden until the next visit. Subscribing first means a +notification can arrive both live and from `fetch()`, and `showOnce()` drops the +second copy by `id`. + +The last page has no cursor, so `after` stays on the cursor before it, and the +next visit reads that page again. That is why the ids are saved along with the +cursor. Only the newest ones can come back, so the last 200 are enough. + +A [gap](/recipes/events-watch-for-changes/#handle-gaps) means notifications +were not delivered, and one also arrives after a reconnect. Calling +`catchUp()` on a gap reads what was missed, and `showOnce()` skips the rest. + +On a first visit there is no saved cursor, so `catchUp()` shows every +notification Puter still keeps. + +## Notes + +- `fetch()` doesn't open the sign-in. On a website with nobody signed in, it + rejects with `token_missing`. In the example above, `puter.kv.get()` signs + the user in first. [Watch for Changes](/recipes/events-watch-for-changes/#watch-a-key) + shows how to start from a sign-in button. +- Leave `after` undefined when there is no cursor. `fetch()` rejects `null` + with `invalid_request`. +- `fetch()` allows 120 calls a minute per user and app, and rejects with + `too_many_requests` past that. Call it on start and on a gap, never on a + timer. The subscription already tells you when something new arrives. See + [Rate Limits and Quotas](/rate-limits-and-quotas/#events). +- `fetch()` rejects with `invalid_subject_audience` for an audience other than + `app-user`, `developer` or `account`, and with `events_disabled` on a server + where events are off. A server with notification events turned off rejects + the `onLocal()` subscription with `invalid_subject`, while `fetch()` still + works. +- A notification subscription counts toward the same subscribe limit and costs + the same per delivery as any other. [Watch for Changes](/recipes/events-watch-for-changes/#stop-watching) + covers `off()`, and its [Notes](/recipes/events-watch-for-changes/#notes) + cover cost. diff --git a/src/docs/src/recipes/events-watch-for-changes.md b/src/docs/src/recipes/events-watch-for-changes.md index 8e095bfbe..1ea10e192 100644 --- a/src/docs/src/recipes/events-watch-for-changes.md +++ b/src/docs/src/recipes/events-watch-for-changes.md @@ -256,7 +256,8 @@ updating without any sign. - [`puter.kv.flush()`](/KV/flush/) sends no event, and neither does a key that expires on its own. - [`puter.events.fetch()`](/Events/fetch/) cannot catch up on key-value or file - changes. It only reads notifications. + changes. It only reads notifications, as in [Catch Up on + Notifications](/recipes/events-catch-up-on-notifications/). - Watching a folder outside your app's own, such as the user's Documents, needs the user's permission first. [Ask for Access](/recipes/perms-ask-for-access/) covers it.