diff --git a/src/docs/src/recipe-tags.js b/src/docs/src/recipe-tags.js index 0597ab551..d1098f14c 100644 --- a/src/docs/src/recipe-tags.js +++ b/src/docs/src/recipe-tags.js @@ -10,6 +10,7 @@ const recipeTags = { perms: 'Permissions', fs: 'File System', kv: 'Key-Value', + events: 'Events', hosting: 'Hosting', workers: 'Workers', ui: 'UI', diff --git a/src/docs/src/recipes/events-watch-for-changes.md b/src/docs/src/recipes/events-watch-for-changes.md new file mode 100644 index 000000000..8e095bfbe --- /dev/null +++ b/src/docs/src/recipes/events-watch-for-changes.md @@ -0,0 +1,265 @@ +--- +title: Watch for Changes +description: "Learn how to keep your app's UI in sync with Puter.js events, so it updates when the user's data changes in another tab or on another device." +tags: [events, kv, fs] +order: 65 +--- + +
The Events API is in beta. Event shapes, limits, and behavior may change between releases.
+ +The [Events API](/Events/) tells your app when the user's data changes. Use it +to keep what is on screen up to date while the page is open: apply settings the +user saved in another tab, update a cart they edited on their phone, or refresh +a file list when files land in a folder. It works with the key-value data and +files from [Store Data](/recipes/store-data/) and +[Store Files](/recipes/store-files/). + +You subscribe with [`puter.events.onLocal()`](/Events/onLocal/). The +subscription lasts until you end it or the page closes. + +## Watch a Key + +To re-render when a key changes, subscribe to `kv:`. The handler runs when +the key changes, whether the change came from this tab, another tab, or another +device. 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. `onLocal()` resolves once the +subscription is in place, so a change made while that read is running still +reaches the handler. If you read first, a change made before the subscription +is ready stays off screen until the key changes again. + +A handler that reads the key again on every event stays correct in every case +on this page, including deletes, merged writes, [gaps](#handle-gaps) and +reconnects. + +On a website, `onLocal()` asks the user to sign in if nobody is, the same way +[`puter.kv.get()`](/KV/get/) does, and rejects with `auth_canceled` if they +close the sign-in. Subjects built from `puter.appID`, like several below, need +the user signed in first, because `puter.appID` is only known after sign-in. +[`puter.auth.signIn()`](/Auth/signIn/) 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 +carries the key's new value as `event.value`, or `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 even when you ask for it, so keep the fallback +to [`puter.kv.get()`](/KV/get/). It is left out when: + +- the value is over 16 KB as JSON, +- more than 128 subscriptions match the same change, or there are too many subscriptions to check for it, +- only the key's lifetime changed (`op: 'expire'`), or +- the event is a [gap](#handle-gaps). + +`includeValue` works on `kv:` subjects only. + +## Watch Several Keys + +A `kv:` subject matches one exact key. This is the opposite of +[`puter.kv.list()`](/KV/list/), which always matches a prefix. 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, ... +``` + +When the keys contain `:`, put your [app ID](/Utils/appID/) right after `kv:`. +The part after `kv:` is read as an app ID whenever more `:` segments follow it, +so `kv:cart:*` names an app called `cart` and is refused. + +Use one subscription for the whole group rather than one per key. A page can +hold 50 subscriptions at a time, and `event.key` already 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)`, +and `showCartItem()` updates one row, or removes it when `item` is empty. + +## Watch a Folder + +To refresh a file list, subscribe to `fs:` and read the folder again with +[`puter.fs.readdir()`](/FS/readdir/). A folder subscription covers everything +inside it, at any depth: + +```js +const dir = `~/AppData/${puter.appID}/uploads`; + +async function showFiles () { + renderFileList(await puter.fs.readdir(dir)); +} + +let queued; +const sub = await puter.events.onLocal(`fs:${dir}`, () => { + queued ??= setTimeout(() => { + queued = undefined; + showFiles(); + }, 500); +}); +await showFiles(); +``` + +Quick writes to one file arrive as one event, but each file gets its own, so +uploading 50 files sends 50 events. The handler above turns them into one read +every half second. That keeps a bulk upload well inside the `readdir` limit of +60 calls per 10 seconds on the free plan. + +The path in an `fs:` subject has to start with `/` or `~`. Other `puter.fs` +methods resolve a relative path such as `'uploads'` inside your app's folder, +`~/AppData//`, but a subject does not, so write the full path. + +The folder doesn't have to exist yet. A subject naming a missing folder covers +the folder and everything added to it later, the same as one naming a folder +that exists. `readdir()` fails until the folder is there, so create it first +with `puter.fs.mkdir(dir, { createMissingParents: true })` if you'd rather show +an empty list. + +## Narrow What You Watch + +When the view shows only part of a folder, say so in the subject. `*` matches +within one folder level, `**` matches across levels, and a suffix limits the +subscription to one kind of change: + +```js +await puter.events.onLocal(`fs:${dir}/*.json`, handler); // JSON files directly in dir +await puter.events.onLocal(`fs:${dir}/*.json:add`, handler); // only new ones +await puter.events.onLocal(`fs:${dir}/**/*.png`, handler); // PNGs at any depth +``` + +Events the subject filters out are never sent. They cost nothing and do not +count toward the subscription's delivery rate. The suffix is one of `add`, +`write`, `move` or `remove`, and it works on `fs:` subjects only. + +## Handle Gaps + +When a limit stops an event from reaching you, the handler gets a gap marker in +its place. It has `op: 'gap'` and no key or path, so all it tells you is that +something changed. Read everything the view shows again: + +```js +const inbox = `~/AppData/${puter.appID}/inbox`; + +const sub = await puter.events.onLocal(`fs:${inbox}/*.json:add`, ({ event }) => { + if (event.op === 'gap') return showInbox(); + addInboxRow(event.path); +}); +``` + +A subscription gets a gap when events for it arrive faster than 600 a minute, or +when one change has more subscriptions to check or deliver to than it is +allowed, or once after the connection drops and comes back (see +[When the Connection Drops](#when-the-connection-drops)). +[Rate Limits and Quotas](/rate-limits-and-quotas/#events) has the numbers. A +handler that reads everything again on every event, like the ones in +[Watch a Key](#watch-a-key) and [Watch a Folder](#watch-a-folder), needs no +extra code. + +Several quick writes to the same key or file can also arrive as one event that +carries the latest state. Treat an event as "this changed", never as a count of +changes. + +## Stop Watching + +To stop, call [`off()`](/Events/off/) on the subscription when the view that +uses it goes away, such as when the user closes a panel or opens a different +folder. Closing the page 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, not on every render. Every `onLocal()` +counts toward 60 subscribes a minute for the user, shared by all apps +(re-subscribing after a reconnect counts too), and `onLocal()` rejects with +`too_many_requests` past that. `off()` has its own, separate limit of 600 a +minute. + +## When the Connection Drops + +All of a page's subscriptions share one connection. When it drops, for example +when a laptop goes to sleep or the network changes, the SDK reconnects and +subscribes again by itself. Changes made while it was down are not sent +afterwards. Instead, once a subscription is back, its handler gets one +[gap](#handle-gaps) with `reason: 'reconnect'`. The handlers above already read +everything again on a gap, so they catch up with no extra code. + +If the SDK cannot restore a subscription, for example because the user signed +out (`reauth_required`), the subscription ends and `onError` is called. 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, and the page stops +updating without any sign. + +## Notes + +- `event.self` is `true` when the signed-in user made the change, from any tab + or device. It does not mean this page made it, so don't use it to skip a + refresh. Changes to your app's own key-value data always have `self: true`. +- Each delivered event costs the user 10 microcents ($0.10 per million) under + the [User-Pays Model](/user-pays-model/). Subscriptions with nothing + happening, events your subject filters out, writes merged into one event, and + gap markers are free. When the user runs out of credit, events stop arriving. + See [Rate Limits and Quotas](/rate-limits-and-quotas/#events). +- [`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. +- 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. +- Work that has to happen while the page is closed, such as processing files + as they arrive, needs a [persistent subscription](/Events/onPersistent/) + instead.