Watch for Changes
The Events API 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 and 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() 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() and show it:
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() 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().
On a website, the user has to be signed in. If nobody is,
onLocal() opens the Puter sign-in window. It rejects with
auth_canceled if the user closes the window.
Some subjects below use puter.appID. The app ID is only
known after sign-in, so sign the user in first. The
puter.auth.signIn() method opens a popup, so call it from a
click:
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:
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(). For example, it's left out on a
gap. Every case is listed under
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 *:
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,
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 for the numbers. The
event's key field tells you which key changed:
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). 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(). A folder subscription
covers everything inside it, at any depth:
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, 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() fails. To
show an empty list instead, create the folder first with
puter.fs.mkdir('uploads', { createMissingParents: true }).
To watch a folder outside your app's own, such as the user's Documents, ask the user for access first. 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:
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:
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. The handlers in Watch a Key and 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() 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.
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() when it
closes. Subscribing on every render can hit the
subscribe rate limit.
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 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:
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 instead.