docs: easy events recipe (PUT-1961) (#3998)

* docs: easy events recipe (PUT-1961)

* docs: catch-up-on-notifications events recipe (PUT-1961)

* docs: notification examples use notif:app-user, which apps can read

* docs: events recipe corrections from a run against a live server (PUT-1961)

* refine and update agents md

---------

Co-authored-by: Reynaldi Chernando <reynaldichernando@gmail.com>
This commit is contained in:
Daniel SalazarandReynaldi Chernando authored and GitHub committed 2026-10-24 15:26:07 +07:00
1 parent b3b02b1ebb
commit f3e8539eaf
8 files changed
+411 -10

No files matched your search

+2 -1
View File
@@ -185,9 +185,10 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **Intro.** Explain the situation and why you'd do this, tied to something the reader already knows ("`puter.kv` is your database in Puter.js"). No "Use this when" bullet list.
- **Structure.** Get to the first working call as early as possible, then add validation, rate limits, and sign-in after it. Explain a Puter concept the first time it appears, such as what `me.puter` is or that `puter.workers.exec()` opens the Puter sign-in window. Split recipes by what the reader needs, not by technique, and give a family of recipes a shared tag.
- **Link to the reference for full option lists.** Mention only the options the recipe uses, then link to the function page for the rest ("All the sort options are listed on [`puter.fs.readdir()`](/FS/readdir/)."). A copied list of options, rules, or scopes goes stale when the reference changes.
- **Limits.** Mention a limit only when it's the reason for a pattern the recipe uses, such as one wildcard subscription instead of one per key, and link to [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) or the function page for the number. Leave out limits that code following the recipe never reaches.
- **Code.** Platform-neutral: just the code, with top-level `await`. No `<script>` or `<html>` wrapper, no IIFE.
- **Tradeoffs.** Justify a choice with one scenario the reader can picture right away, in about two short paragraphs, or fold each point into the section where it matters. The description states the benefit only.
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit goes in the section it applies to; other facts belong on the linked reference page.
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit the recipe needs goes in the section it applies to, and other facts belong on the linked reference page.
---
+2 -2
View File
@@ -20,7 +20,7 @@ What you watch is written as a subject string:
| `fs:~/Documents` | Everything under a folder, or one file by path or uid |
| `fs:~/inbox/*.json:add` | Matching paths, one kind of change |
| `kv:cart` | One key in your app's key-value store (`kv:cart*` for a prefix) |
| `notif:account` | The user's notifications |
| `notif:app-user` | Notifications about the user's use of your app |
See [Subjects and events](/Events/subjects/) for the full subject syntax, the event shape, and how to share key-value events with another user.
@@ -239,7 +239,7 @@ With the [User-Pays Model](/user-pays-model/), deliveries are billed to the user
let seen = 0;
do {
const page = await puter.events.fetch({
subject: 'notif:account',
subject: 'notif:app-user',
cursor,
});
for (const event of page.items) {
+4 -4
View File
@@ -25,7 +25,7 @@ puter.events.fetch(options)
#### `options` (Object) (required)
- `subject` (String) (required): What to read: `notif:account` (the account's notifications), `notif:app-user` (the ones belonging to the app you're running as), or `notif:<appId>:<audience>`. Audiences are `account`, `developer` (about an app, sent to its owner), and `app-user` (about your data inside an app).
- `subject` (String) (required): What to read: `notif:app-user` (the ones belonging to the app you're running as), `notif:developer` (about your app, for its owner), or `notif:<appId>:<audience>`. Audiences are `account`, `developer` (about an app, sent to its owner), and `app-user` (about your data inside an app). Apps and websites never see `account` notifications: `notif:account` returns an empty page for them.
- `cursor` (String): The `cursor` from the previous page. Leave it off to start from the oldest notification still kept.
- `after` (String): The older name for `cursor`, still accepted. If both are given, `cursor` is used.
- `limit` (Number): Events per page. Defaults to 50, max 200.
@@ -83,7 +83,7 @@ The promise rejects with `{ message, code }`: `auth_canceled` if nobody was sign
let seen = 0;
do {
const page = await puter.events.fetch({
subject: 'notif:account',
subject: 'notif:app-user',
cursor,
});
for (const event of page.items) {
@@ -118,10 +118,10 @@ The promise rejects with `{ message, code }`: `auth_canceled` if nobody was sign
// Live first, so nothing arriving during the catch-up is lost —
// `id` is what makes the overlap harmless.
const sub = await puter.events.onLocal('notif:account',
const sub = await puter.events.onLocal('notif:app-user',
({ event }) => show(event));
const page = await puter.events.fetch({ subject: 'notif:account' });
const page = await puter.events.fetch({ subject: 'notif:app-user' });
page.items.forEach(show);
await sub.off();
@@ -13,10 +13,10 @@
// Live first, so nothing arriving during the catch-up is lost —
// `id` is what makes the overlap harmless.
const sub = await puter.events.onLocal('notif:account',
const sub = await puter.events.onLocal('notif:app-user',
({ event }) => show(event));
const page = await puter.events.fetch({ subject: 'notif:account' });
const page = await puter.events.fetch({ subject: 'notif:app-user' });
page.items.forEach(show);
await sub.off();
@@ -7,7 +7,7 @@
let seen = 0;
do {
const page = await puter.events.fetch({
subject: 'notif:account',
subject: 'notif:app-user',
cursor,
});
for (const event of page.items) {
+1
View File
@@ -10,6 +10,7 @@ const recipeTags = {
perms: 'Permissions',
fs: 'File System',
kv: 'Key-Value',
events: 'Events',
hosting: 'Hosting',
workers: 'Workers',
email: 'Email',
@@ -0,0 +1,156 @@
---
title: Catch Up on Notifications
description: "Learn how to show Puter notifications in your app with Puter.js, including the ones that arrived while your app was closed."
tags: [events]
order: 66
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
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/watch-for-changes/), which
covers what every subscription shares: sign-in, gaps, stopping and reconnects.
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()`](/Events/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.
Tell notifications apart by `id` only. The `seq` field is only a position within
one page or one delivery, two notifications can share a `ts`, and the same
notification's `ts` can differ between its live and fetched copies.
## 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, such as a
[persistent subscription](/Events/onPersistent/) your app made for them
that has ended or stopped delivering (`app.events.ended`).
- `notif:developer` is about your app itself, for the account that owns it,
such as your app's worker deploys (`app.worker.deployed`,
`app.worker.deployFailed`) and suspended event handlers
(`app.events.suspended`). For any other user,
[`onLocal()`](/Events/onLocal/) rejects with `subject_does_not_exist` and
[`fetch()`](/Events/fetch/) returns an empty page.
Apps and websites never see `notif:account`, which holds account notifications
such as a file shared with the user. 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.
All the subjects are listed under
[Notifications](/Events/subjects/#notifications).
## 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
`cursor` until a page comes back without one:
```js
let cursor;
do {
const page = await puter.events.fetch({ subject: 'notif:app-user', cursor });
page.items.forEach(showNotification);
cursor = page.cursor;
} while (cursor);
```
Without a `cursor`, [`fetch()`](/Events/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.
The [`fetch()`](/Events/fetch/) method 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.
Call [`fetch()`](/Events/fetch/) on start and on a gap. The subscription tells
you when something new arrives, so there's no need to call it on a timer.
Only `notif:` subjects can be fetched. To catch up on key-value or file
changes, read the data itself again, as in
[Watch for Changes](/recipes/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 cursor = saved.cursor;
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', { cursor, seen: [...seen].slice(-200) });
}
async function catchUp () {
let page;
do {
page = await puter.events.fetch({ subject, cursor });
page.items.forEach(showOnce);
cursor = page.cursor ?? cursor;
} 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. Because of that, a notification can arrive both live and
from [`fetch()`](/Events/fetch/), and `showOnce()` drops the second copy by
`id`.
The last page has no cursor, so the saved cursor stays on the page 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. On a
first visit there is no saved cursor, so `cursor` is `undefined` and
`catchUp()` shows every notification Puter still keeps.
A [gap](/recipes/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.
+243
View File
@@ -0,0 +1,243 @@
---
title: Watch for Changes
description: "Learn how to update your app's screen with Puter.js when the user's data changes in another tab, on another device, or in another app."
tags: [events, kv, fs]
order: 65
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
The [Events API](/Events/) tells your app when the user's key-value data or
files change, so it can show the new data. It works with the data from
[Store Data](/recipes/store-data/) and [Store Files](/recipes/store-files/).
The handler runs for every change, including the ones your own code makes. So
you can write data anywhere in your code and update the screen in one place,
the handler. It also runs for changes made somewhere else:
- The user has your app open in another tab, or on another device.
- A worker or another app writes to the same keys or folder.
- The user adds files to the folder from the Puter desktop.
- Another person writes to a folder shared with the user.
To subscribe, use the [`puter.events.onLocal()`](/Events/onLocal/) method. The
subscription lasts until you end it or the user closes the tab.
## Watch a Key
To re-render when a key changes, subscribe to `kv:<key>`. In the handler, read
the key again with [`puter.kv.get()`](/KV/get/) and show it:
```js
async function showSettings () {
const settings = await puter.kv.get('settings') ?? { theme: 'light' };
applySettings(settings);
}
const sub = await puter.events.onLocal('kv:settings', showSettings);
await showSettings();
```
Subscribe first, then do the first read. The
[`onLocal()`](/Events/onLocal/) promise resolves once the subscription is in
place. A change made during the first read then still reaches the handler.
A key that expires on its own sends no event. Neither does
[`puter.kv.flush()`](/KV/flush/).
On a website, the user has to be signed in. If nobody is,
[`onLocal()`](/Events/onLocal/) opens the Puter sign-in window. It rejects with
`auth_canceled` if the user closes the window.
Some subjects below use [`puter.appID`](/Utils/appID/). The app ID is only
known after sign-in, so sign the user in first. The
[`puter.auth.signIn()`](/Auth/signIn/) method opens a popup, so call it from a
click:
```js
if (puter.auth.isSignedIn()) {
await startWatching();
} else {
signInButton.addEventListener('click', async () => {
await puter.auth.signIn();
await startWatching();
});
}
```
Here `startWatching()` runs the subscriptions from the sections below. An app
running on Puter is always signed in, so it can call it directly.
## Get the New Value With the Event
To skip the read in the handler, pass `includeValue: true`. Each event then
has the key's new value in `event.value`. The value is `null` when the key was
deleted:
```js
const sub = await puter.events.onLocal('kv:settings', async ({ event }) => {
const settings = 'value' in event ? event.value : await puter.kv.get('settings');
applySettings(settings ?? { theme: 'light' });
}, { includeValue: true });
```
The value is sometimes left out, so keep the fallback to
[`puter.kv.get()`](/KV/get/). For example, it's left out on a
[gap](#handle-gaps). Every case is listed under
[Getting the new value](/Events/subjects/#getting-the-new-value).
The `includeValue` option works on `kv:` subjects only.
## Watch Several Keys
A `kv:` subject matches one exact key. To watch every key that starts with the
same text, end the subject with `*`:
```js
await puter.events.onLocal('kv:draft*', handler); // draft, drafts, draft-2, ...
await puter.events.onLocal(`kv:${puter.appID}:cart:*`, handler); // cart:apple, cart:pear, ...
```
If the keys contain `:`, start the subject with your [app ID](/Utils/appID/),
as in the second line. Without it, `kv:cart:*` is read as the keys of an app
called `cart`, and the subscription is refused.
Use one subscription for the whole group, since a tab can hold only a limited
number of subscriptions. See
[Rate Limits and Quotas](/rate-limits-and-quotas/#events) for the numbers. The
event's `key` field tells you which key changed:
```js
const sub = await puter.events.onLocal(`kv:${puter.appID}:cart:*`, async ({ event }) => {
if (event.op === 'gap') return showCart();
const item = 'value' in event ? event.value : await puter.kv.get(event.key);
showCartItem(event.key, item);
}, { includeValue: true });
await showCart();
```
Here `showCart()` loads the whole cart with
[`puter.kv.list('cart:*', true)`](/KV/list/). The `showCartItem()` function
updates one row, or removes it when `item` is empty.
## Watch a Folder
To refresh a file list, subscribe to `fs:<path>`. In the handler, read the
folder again with [`puter.fs.readdir()`](/FS/readdir/). A folder subscription
covers everything inside it, at any depth:
```js
async function showFiles () {
renderFileList(await puter.fs.readdir('uploads'));
}
let queued;
const sub = await puter.events.onLocal('fs:uploads', () => {
queued ??= setTimeout(() => {
queued = undefined;
showFiles();
}, 500);
});
await showFiles();
```
A relative path in a subject works the same way as in [`puter.fs`](/FS/), so
`fs:uploads` is the `uploads` folder inside your app's folder.
Each file gets its own event, so uploading 50 files sends 50 events. The
handler above turns them into one read every half second.
The folder doesn't have to exist yet. The subscription starts delivering events
when the folder is created. Until then [`readdir()`](/FS/readdir/) fails. To
show an empty list instead, create the folder first with
[`puter.fs.mkdir('uploads', { createMissingParents: true })`](/FS/mkdir/).
To watch a folder outside your app's own, such as the user's Documents, ask the
user for access first. [Ask for Access](/recipes/ask-for-access/) shows how.
## Narrow What You Watch
When the view shows only part of a folder, say so in the subject. A `*` matches
within one folder level. A `**` matches across levels. A suffix limits the
subscription to one kind of change:
```js
await puter.events.onLocal('fs:uploads/*.json', handler); // JSON files directly in uploads
await puter.events.onLocal('fs:uploads/*.json:add', handler); // only new ones
await puter.events.onLocal('fs:uploads/**/*.png', handler); // PNGs at any depth
```
The suffix is one of `add`, `write`, `move` or `remove`. It works on `fs:`
subjects only. Events the subject filters out are never sent.
## Handle Gaps
Sometimes an event can't be delivered. The handler then gets a gap marker in
its place. A gap marker has `op: 'gap'` and no key or path. It only
tells you that something changed, so read everything the view shows again:
```js
const sub = await puter.events.onLocal('fs:inbox/*.json:add', ({ event }) => {
if (event.op === 'gap') return showInbox();
addInboxRow(event.path);
});
```
A gap also arrives after the connection drops and comes back, as covered in
[When the Connection Drops](#when-the-connection-drops). The handlers in [Watch a Key](#watch-a-key) and [Watch a Folder](#watch-a-folder)
read everything again on every event, so they need no extra code for gaps.
Several quick writes to the same key or file can arrive as one event with the
latest state. So one event can stand for more than one change.
## Stop Watching
To stop, call [`off()`](/Events/off/) on the subscription when the view that
uses it goes away. For example, the user closes a panel or opens a different
folder. Closing the tab ends every subscription on its own.
```js
let sub;
async function openFolder (dir) {
await sub?.off();
sub = await puter.events.onLocal(`fs:${dir}`, () => showFolder(dir));
await showFolder(dir);
}
```
Subscribe once when a view opens, and call [`off()`](/Events/off/) when it
closes. Subscribing on every render can hit the
[subscribe rate limit](/rate-limits-and-quotas/#events).
## When the Connection Drops
All the subscriptions in one tab share one connection. The connection can drop,
for example when a laptop goes to sleep or the network changes. The SDK then
reconnects and subscribes again by itself.
Changes made while the connection was down are not sent later. Instead, each
subscription's handler gets one [gap](#handle-gaps) with `reason: 'reconnect'`.
The handlers above read everything again on a gap, so they catch up with no
extra code.
The SDK can fail to restore a subscription, for example after the user signs
out. The subscription then ends and `onError` is called. The same happens, with
the code `subscription_ended`, when the watched file or folder is deleted.
Nothing arrives after that, so tell the user the view has stopped updating:
```js
const sub = await puter.events.onLocal('kv:settings', showSettings, {
onError: (error) => showBanner(`Live updates stopped (${error.code})`),
});
```
Without `onError` the error is only logged to the console. Your app then stops
updating without any sign.
## Notes
- Work that has to happen while your app is closed, such as processing files
as they arrive, needs a [persistent subscription](/Events/onPersistent/)
instead.