docs: medium events recipes (PUT-1971) (#4333)

* docs: easy events recipe (PUT-1961)

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

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

* docs: medium events recipes (PUT-1971)

* docs: simpler wording in medium events recipes (PUT-1971)

* refine

---------

Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
Reynaldi ChernandoandDaniel Salazar authored and GitHub committed 2026-10-25 19:18:55 +07:00
1 parent 0c67499dc2
commit c7cc64e45c
4 files changed
+964 -1

No files matched your search

+1
View File
@@ -184,6 +184,7 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **Description.** Written for someone who has never heard of Puter. Start with "Learn how to", put the action first, and say what the reader's app gains: "Learn how to add file sharing to your app with Puter.js, so users can share files in their Puter account with other people." No API names, no mechanism, no list of example cases, and no "not just …" contrast.
- **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.
- **Reading order.** A recipe reads top to bottom. Each code block uses only functions and variables from blocks above it, so the reader never has to search further down the page to understand it. Start with a small version that works, and have each section add one case. When a section changes a function from earlier, show the whole new version of that function there. If the code is built up over several sections, end with a `## Full Code` block that has all of it.
- **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.
+1 -1
View File
@@ -38,7 +38,7 @@ puter.events.onPersistent(options)
Running your handler while the user isn't there needs its own per-app permission, **`events:background`**. Subscribing with `worker` among the `targets` (the default for an app) without it fails with `events_background_consent_required`. Request it like any other permission:
```js
await puter.perms.request(['events:background']);
await puter.perms.request('events:background');
```
The user can revoke it wherever they manage the app's access. That suspends every worker-target subscription the app holds for them with `permission_revoked`, and granting it again doesn't resume them: subscribe again. A subscription that only wants deliveries while your app is open needs no consent: pass `targets: ['socket']`.
@@ -0,0 +1,506 @@
---
title: Keep an Open File in Sync
description: "Learn how to build an editor with Puter.js events that reloads a file when it changes somewhere else, protects unsaved work, and follows the file when it's renamed or deleted."
tags: [events, fs]
order: 67
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
Say your app is a text editor. While a file is open, the user might change it
somewhere else: edit it in another tab or on their phone, rename it on the
Puter desktop, or move it to the Trash. Desktop code editors handle this with a
"file changed on disk" message. This recipe builds the same thing with
[`puter.events.onLocal()`](/Events/onLocal/), which calls a function every time
the file changes.
It builds on [Watch for Changes](/recipes/watch-for-changes/), which
covers gaps, reconnects and cost. The examples use a few functions you'd write
yourself: `editor.getValue()` and `editor.setValue()` for the text area,
`setTitle()` for the window title, and `showBanner()` to show a message with
optional buttons.
The recipe starts with a small editor that works, then adds one case per
section. Each section shows only the functions it adds or changes. The
[full code](#full-code) is at the end.
## Watch the File by Its ID
Every file in Puter has a `uid`, an ID that doesn't change when the file is
renamed or moved. To get events for a file, subscribe to `fs:` plus its `uid`.
This first version loads the file again whenever its contents change:
```js
let file; // the open file
let path; // where the file is now
let saved = ''; // the file's text as this tab last loaded or saved it
let sub;
async function readFile () {
const blob = await puter.fs.read(file.uid);
return blob.text();
}
async function handleFileEvent (event) {
if (event.op === 'write') {
saved = await readFile();
editor.setValue(saved);
}
}
function onFileEvent ({ event }) {
return handleFileEvent(event);
}
function onWatchError (err) {
showBanner(`Live updates stopped (${err.code}).`);
}
async function openFile (item) {
await sub?.off();
file = item;
path = item.path;
sub = await puter.events.onLocal(`fs:${item.uid}`, onFileEvent, {
onError: onWatchError,
});
saved = await readFile();
editor.setValue(saved);
setTitle(item.name);
}
const item = await puter.ui.showOpenFilePicker();
await openFile(item);
```
A `write` event means the contents changed. It doesn't include the new text,
so `handleFileEvent()` reads the file again.
When the user picks a file with
[`showOpenFilePicker()`](/UI/showOpenFilePicker/), your app gets access to that
file, so it can subscribe to it without requesting a permission.
A few things to note about `openFile()`:
- It subscribes before it reads the file. If the file changes while it's being
read, the subscription gets an event for that change, and the handler reads
the file again.
- It reads the file by `uid` with [`puter.fs.read()`](/FS/read/). The picked
item also has a `read()` method, but that method uses the path the file had
when it was picked, so it fails after the file is renamed.
- The `onError` callback runs if the subscription stops, and shows a banner
with the error code.
A subscription by `uid` also stays on the same file after a reconnect. When the
connection drops and comes back, the SDK subscribes again. For a path subject
like `fs:~/Documents/notes.txt`, it looks up the path again at that point. If
the file was renamed while the connection was down, the new subscription is for
the old path, not for the open file.
## Handle One Thing at a Time
The SDK doesn't wait for your handler to finish before calling it again. If two
events arrive close together, two reads run at the same time, and the older
read can finish last and put old text in the editor. Saving, added in the next
section, has the same problem when a reload runs during a save. To prevent
this, run opening, saving and event handling through a queue that runs one task
at a time:
```js
let last = Promise.resolve();
function enqueue (task) {
const previous = last;
last = (async () => {
try {
await previous;
} catch {
// ignore the previous task's error and keep going
}
return task();
})();
return last;
}
```
Then call `handleFileEvent()` and `openFile()` through the queue. This replaces
`onFileEvent()` and the call to `openFile()`:
```js
function onFileEvent ({ event }) {
return enqueue(() => handleFileEvent(event));
}
const item = await puter.ui.showOpenFilePicker();
await enqueue(() => openFile(item));
```
## Save and Tell Your Own Saves Apart
To save, write the editor's text to the file's path with
[`puter.fs.write()`](/FS/write/). Saving overwrites the file in place, so the
`uid` stays the same and the subscription keeps working.
When this tab saves, it also gets a `write` event for that save. That event
costs the user the same as any other, under the
[User-Pays Model](/user-pays-model/). The `event.self` field can't separate
this tab's saves from other changes, because it's `true` for every change the
signed-in user makes, from any tab or device.
Instead, save through the queue and set `saved` after the write. The handler
for the save's event runs after the save task finishes, and by then `saved`
holds the text that was written. The new `reloadIfChanged()` function reads the
file and returns without updating the editor when the text matches `saved`:
```js
function save () {
return enqueue(async () => {
const text = editor.getValue();
await puter.fs.write(path, text);
saved = text;
});
}
async function reloadIfChanged () {
const latest = await readFile();
if (latest === saved) return; // nothing new, or this tab's own save
saved = latest;
editor.setValue(latest);
}
async function handleFileEvent (event) {
if (event.op === 'write') return reloadIfChanged();
}
```
Writes less than 250 ms apart arrive as one event, so one event can come from
several writes. The handler reads the whole file, so it gets the latest text
either way.
If two tabs save at almost the same time, the file has the text from the last
write, and the other tab reloads it. A change written somewhere else right
before your save is overwritten without a warning. To catch that, read the file
just before writing and compare it with `saved`.
## Handle Unsaved Changes
So far, the editor replaces its text whenever the file changes, which deletes
edits the user hasn't saved. Puter doesn't lock or merge files, so the last
write replaces the file. When the user has unsaved edits, ask which version to
keep.
The `saved` variable holds the text this tab last loaded or wrote. Comparing the
editor with `saved` tells you whether the user has unsaved edits, and comparing
the file with `saved` tells you whether the file actually changed. This version
of `reloadIfChanged()` checks both:
```js
function showConflict () {
showBanner('This file was changed somewhere else.', {
'Load their version': () => enqueue(async () => {
saved = await readFile();
editor.setValue(saved);
}),
'Keep mine': () => save(),
});
}
async function reloadIfChanged () {
const latest = await readFile();
if (latest === saved) return; // nothing new, or this tab's own save
if (editor.getValue() === saved) { // no unsaved edits, so load it
saved = latest;
editor.setValue(latest);
} else {
showConflict(); // changed in both places
}
}
```
"Load their version" reads the file again when clicked, in case it changed
while the banner was open. "Keep mine" writes the editor's text over the other
version.
## Follow Renames and Moves
A `move` event means the file was renamed or moved. The event's `path` is the
new path and `from` is the old one. Keep `path` up to date, because
[`puter.fs.write()`](/FS/write/) only takes a path. If you save to the old path
after a rename, Puter creates a new file there instead of updating the one
that's open.
Deleting a file on the Puter desktop moves it to the user's Trash folder
(`/<username>/Trash`), so you get a `move`, not a `remove`. The `onMoved()`
function below sets `path` to `null` while the file is in the Trash. If the
user restores the file, it gets another `move` event, and `onMoved()` sets
`path` to the restored location:
```js
const inTrash = (p) => /^\/[^/]+\/Trash\//.test(p);
function onMoved (event) {
if (inTrash(event.path)) {
path = null;
showBanner('This file was moved to the Trash.');
return;
}
path = event.path;
setTitle(event.path.split('/').pop());
}
async function refreshPath () {
const info = await puter.fs.stat({ uid: file.uid });
onMoved({ path: info.path });
}
```
Renaming or moving the folder the file is in also changes the file's path, but
sends no event for the file. The `refreshPath()` function looks up the current
path with [`puter.fs.stat()`](/FS/stat/). Call it before each save, and skip
the save while the file is in the Trash:
```js
function save () {
return enqueue(async () => {
await refreshPath(); // its folder may have moved
if (!path) return; // the file is in the Trash
const text = editor.getValue();
await puter.fs.write(path, text);
saved = text;
});
}
```
A [gap](/recipes/watch-for-changes/#handle-gaps) means some events may have
been missed, for example while the laptop was asleep. On a gap, refresh the
path and then read the file again. Here's `handleFileEvent()` with the `move`
and `gap` cases added:
```js
async function handleFileEvent (event) {
if (event.op === 'write') return reloadIfChanged();
if (event.op === 'move') return onMoved(event);
if (event.op === 'gap') {
await refreshPath();
await reloadIfChanged();
}
}
```
## When the File Is Deleted
A file is permanently deleted when the user empties the Trash, or when an app
calls [`puter.fs.delete()`](/FS/delete/). You usually get a `remove` event
first. Then the subscription ends, and `onError` is called with
`code: 'subscription_ended'` and `reason: 'anchor_deleted'`. If the file was
deleted while the connection was down, `onError` gets `subject_does_not_exist`
instead when the SDK reconnects. Either way, no more events arrive.
The text is still in the editor, so offer to save it as a new file. This
replaces `onWatchError()`:
```js
function onDeleted () {
path = null;
showBanner('This file was deleted. Save to keep your text as a new file.');
}
function onWatchError (err) {
if (err.reason === 'anchor_deleted' || err.code === 'subject_does_not_exist') {
return onDeleted();
}
showBanner(`Live updates stopped (${err.code}).`);
}
async function saveAsNewFile () {
try {
const item = await puter.ui.showSaveFilePicker(editor.getValue(), file.name);
await enqueue(() => openFile(item));
} catch (err) {
showBanner(`Couldn't save: ${err.message}`);
}
}
```
[`showSaveFilePicker()`](/UI/showSaveFilePicker/) writes the text where the
user chooses and resolves to the new file, which `openFile()` then watches.
Now `save()` can save as a new file when there's no path to write to. After the
file is permanently deleted, the [`puter.fs.stat()`](/FS/stat/) call in
`refreshPath()` throws, and `save()` handles that the same way:
```js
function save () {
return enqueue(async () => {
try {
await refreshPath(); // its folder may have moved
} catch {
path = null; // the file was deleted
}
if (!path) {
saveAsNewFile(); // no await
return;
}
const text = editor.getValue();
await puter.fs.write(path, text);
saved = text;
});
}
```
If the user cancels the save picker, the promise never resolves. That's why
`save()` calls `saveAsNewFile()` without `await`. If the queue waited for it,
no task after it in the queue would run.
The last case for `handleFileEvent()` is `remove`:
```js
async function handleFileEvent (event) {
if (event.op === 'write') return reloadIfChanged();
if (event.op === 'move') return onMoved(event);
if (event.op === 'remove') return onDeleted();
if (event.op === 'gap') {
await refreshPath();
await reloadIfChanged();
}
}
```
You can also add a third button to `showConflict()` that keeps the user's text
as a new file by calling `saveAsNewFile()`.
## Full Code
Here's the finished editor in one block:
```js
let file; // the open file
let path; // where the file is now
let saved = ''; // the file's text as this tab last loaded or saved it
let sub;
let last = Promise.resolve();
function enqueue (task) {
const previous = last;
last = (async () => {
try {
await previous;
} catch {
// ignore the previous task's error and keep going
}
return task();
})();
return last;
}
async function readFile () {
const blob = await puter.fs.read(file.uid);
return blob.text();
}
function showConflict () {
showBanner('This file was changed somewhere else.', {
'Load their version': () => enqueue(async () => {
saved = await readFile();
editor.setValue(saved);
}),
'Keep mine': () => save(),
});
}
async function reloadIfChanged () {
const latest = await readFile();
if (latest === saved) return; // nothing new, or this tab's own save
if (editor.getValue() === saved) { // no unsaved edits, so load it
saved = latest;
editor.setValue(latest);
} else {
showConflict(); // changed in both places
}
}
const inTrash = (p) => /^\/[^/]+\/Trash\//.test(p);
function onMoved (event) {
if (inTrash(event.path)) {
path = null;
showBanner('This file was moved to the Trash.');
return;
}
path = event.path;
setTitle(event.path.split('/').pop());
}
async function refreshPath () {
const info = await puter.fs.stat({ uid: file.uid });
onMoved({ path: info.path });
}
function onDeleted () {
path = null;
showBanner('This file was deleted. Save to keep your text as a new file.');
}
async function handleFileEvent (event) {
if (event.op === 'write') return reloadIfChanged();
if (event.op === 'move') return onMoved(event);
if (event.op === 'remove') return onDeleted();
if (event.op === 'gap') {
await refreshPath();
await reloadIfChanged();
}
}
function onFileEvent ({ event }) {
return enqueue(() => handleFileEvent(event));
}
function onWatchError (err) {
if (err.reason === 'anchor_deleted' || err.code === 'subject_does_not_exist') {
return onDeleted();
}
showBanner(`Live updates stopped (${err.code}).`);
}
async function openFile (item) {
await sub?.off();
file = item;
path = item.path;
sub = await puter.events.onLocal(`fs:${item.uid}`, onFileEvent, {
onError: onWatchError,
});
saved = await readFile();
editor.setValue(saved);
setTitle(item.name);
}
async function saveAsNewFile () {
try {
const item = await puter.ui.showSaveFilePicker(editor.getValue(), file.name);
await enqueue(() => openFile(item));
} catch (err) {
showBanner(`Couldn't save: ${err.message}`);
}
}
function save () {
return enqueue(async () => {
try {
await refreshPath(); // its folder may have moved
} catch {
path = null; // the file was deleted
}
if (!path) {
saveAsNewFile(); // no await
return;
}
const text = editor.getValue();
await puter.fs.write(path, text);
saved = text;
});
}
const item = await puter.ui.showOpenFilePicker();
await enqueue(() => openFile(item));
```
@@ -0,0 +1,456 @@
---
title: Run Code in the Background
description: "Learn how to run your own code with Puter.js events when a user's files change, even while your app is closed, like a webhook or a background job."
tags: [events, fs, kv]
order: 68
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
Some work should happen even when nobody has your app open. For example,
recording each file a user adds to a folder, posting to a chat when a report is
saved, or processing a file as soon as it's uploaded. On other platforms you'd
use a webhook, a background job, or a cloud function that runs on upload.
In Puter, you write a function, publish it once, and each user's subscription
runs it when their files change. Puter runs it in your app's
[events worker](/Events/workers/), a serverless function that belongs to your
app. You don't need to run a server.
[Watch for Changes](/recipes/watch-for-changes/) covers subscriptions
that end when the page closes. This recipe uses persistent subscriptions,
created with [`puter.events.onPersistent()`](/Events/onPersistent/). They're
saved to the user's account and keep running after your app closes.
## The Three Parts
The setup has three parts:
- **The handler**: the function to run. You publish it once, as the app's
developer.
- **Permission**: each user has to allow your app to run code while the app is
closed.
- **The subscription**: created once per user by your app. It says what to
watch and which handler to run.
The examples build one app. Users add files to the app's `Inbox` folder, and
the handler saves a record of each new file in the app's
[key-value store](/KV/), so the app can show them later.
## Publish the Handler
Publish with [`puter.events.handlers.publish()`](/Events/handlers/). This is a
developer step, like a deploy, and it only works for the account that owns the
app. Run it from a deploy script or your browser console. For any other account
it fails with `events_handler_forbidden`.
Here's the handler and the call that publishes it:
```js
const app = await puter.apps.get('drop-box'); // your app's name
async function recordUpload ({ event, user }) {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
await user.kv.set(`uploads:${event.uid}`, {
name: event.path.split('/').pop(),
addedAt: event.ts,
});
}
await puter.events.handlers.publish('recordUpload', recordUpload, { appUid: app.uid });
```
The handler gets one object as its argument. This handler uses two of its
fields:
- `event`: what changed, in the [same shape](/Events/onLocal/#the-event) as
[`onLocal()`](/Events/onLocal/) events. The file's ID is `event.uid`.
- `user`: a `puter` object signed in as the user who owns the subscription. It
can do whatever your app can do for that user, like `user.kv` and `user.fs`.
It's valid for 15 minutes, so use it only during the run.
Your script doesn't run the handler. Puter stores the handler's code and runs
it later in the events worker. So the handler can't use variables or functions
defined outside it, or `puter`. It uses `user` instead of `puter`. It can use
its parameters, its own variables, and standard globals like `fetch`, `JSON`
and `Math`. The [`publish()`](/Events/handlers/) method checks this, and fails
with `events_handler_free_variable` if the handler uses a name it doesn't
define.
The handler only acts on new files. Uploads and copies arrive as `add`. A file
moved in from another folder, for example by dragging it on the Puter desktop,
arrives as a `move` with no `from`, because it came from outside the folder.
Everything else is skipped, including edits, deletes and
[gaps](/recipes/watch-for-changes/#handle-gaps).
A skipped event still runs the handler. Each run is billed to the user as
worker usage, on top of the cost per event, under the
[User-Pays Model](/user-pays-model/), and counts toward the limit of 60 runs a
minute per user and app. For example, every save to a file that's already in
`Inbox` is one run.
A new folder in `Inbox` also arrives as an `add`. To handle only files, check
the item with `user.fs.stat({ uid: event.uid })`. Dragging a folder in sends one
`move` for the folder and nothing for the files inside it, so list its contents
with `user.fs.readdir()` to handle those.
## Ask for Permission
Running code while your app is closed needs the user's permission. Ask from
your app with [`puter.perms.request()`](/Perms/request/):
```js
const allowed = await puter.perms.request('events:background');
```
It resolves to `true` or `false`. If the permission is already granted, the
user doesn't see a prompt. To check without prompting, for example to decide
whether to show an "Enable background processing" button, use
[`puter.perms.check('events:background')`](/Perms/check/).
Without the permission, [`onPersistent()`](/Events/onPersistent/) fails with
`events_background_consent_required`. The user can also revoke the permission
later. That pauses the app's subscriptions for that user with the reason
`permission_revoked`. They stay paused even if the user grants the permission
again, so your app has to subscribe again.
Persistent subscriptions need a Puter account. For a guest account,
[`onPersistent()`](/Events/onPersistent/) fails with
`events_durable_requires_account`.
## Subscribe Once per User
Every [`onPersistent()`](/Events/onPersistent/) call creates a new
subscription, even with the same options. If you call it each time the app
opens, each call adds another subscription, every one of them runs the handler
for each event, and the user eventually reaches the limit of 25 subscriptions
per app (100 on a paid plan). Check [`puter.events.list()`](/Events/list/)
first:
```js
const inbox = `~/AppData/${puter.appID}/Inbox`;
const options = { subject: `fs:${inbox}`, handlerName: 'recordUpload' };
async function findSubscription () {
const subs = await puter.events.list();
return subs.find((s) =>
s.subject === options.subject && s.handlerName === options.handlerName);
}
async function startBackgroundWork () {
const folder = await puter.fs.mkdir(inbox, { createMissingParents: true });
const existing = await findSubscription();
const working = existing
&& existing.anchor.uid === folder.uid
&& existing.suspendedReason !== 'permission_revoked';
if (working) return;
if (existing) await puter.events.unsubscribe(existing.subId);
await puter.events.onPersistent(options);
}
```
Call `startBackgroundWork()` when the app opens, after the user has given
permission. A few details:
- An `fs:<folder>` subject matches changes anywhere inside the folder.
- The [`mkdir()`](/FS/mkdir/) call creates `Inbox` if it's missing, and returns
the existing folder if it's already there.
- A subscription is tied to the folder itself, not its name. If the user
deletes `Inbox` on the desktop, the folder moves to the Trash, and the
subscription stays attached to the folder in the Trash. The code compares
`anchor.uid` (the ID of the folder the subscription is attached to) with the
folder from `mkdir()`. When they differ, it subscribes to the new folder.
- A paused subscription has its reason in `suspendedReason`. One paused with
`permission_revoked` stays paused after the user grants the permission again,
so the code replaces it. Subscriptions paused for other reasons resume
without your app doing anything, so the code keeps them.
The [`list()`](/Events/list/) method only returns your app's subscriptions, with
`subject` exactly as you passed it. If two tabs open at the same moment, both
might create a subscription. If [`list()`](/Events/list/) returns more than one
match, keep one and unsubscribe the rest.
## Pass Settings to the Handler
The handler can't see your app's variables, so per-user settings go in
`context` when you subscribe, and the handler gets them as `ctx`. For example,
if the user saved a chat webhook URL in your app's settings, add it to
`options` before calling `startBackgroundWork()`:
```js
options.context = { webhookUrl: settings.webhookUrl };
```
Then use it in the handler, and publish the handler again:
```js
async function recordUpload ({ event, user, ctx }) {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
const name = event.path.split('/').pop();
await user.kv.set(`uploads:${event.uid}`, { name, addedAt: event.ts });
if (ctx.webhookUrl) {
await fetch(ctx.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `New file: ${name}` }),
});
}
}
```
The `context` is saved when the subscription is created and never updated. If the
user changes the setting, replace the subscription:
```js
async function updateWebhook (webhookUrl) {
options.context = { webhookUrl };
const existing = await findSubscription();
if (existing) await puter.events.unsubscribe(existing.subId);
await puter.events.onPersistent(options);
}
```
This calls [`onPersistent()`](/Events/onPersistent/) directly instead of
`startBackgroundWork()`, because [`list()`](/Events/list/) can still return the
old subscription for a short time after an unsubscribe.
The `context` can be up to 4 KB of JSON, and anything bigger fails with
`events_context_too_large`. For more data, store it with [`puter.kv`](/KV/) and
read it in the handler with `user.kv.get()`. The [`list()`](/Events/list/)
method shows the key names in `context`, never the values.
To end a subscription automatically, set `expiresAt` in unix seconds or as an
ISO date string. `Date.now()` is in milliseconds, so divide it by 1000:
```js
options.expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; // one week from now
```
## Write Output Outside the Watched Folder
Write the handler's output somewhere the subscription isn't watching, like a
`Processed` folder next to `Inbox`, or the key-value store.
A file the handler writes into the folder it's watching is a new change, so the
handler runs again. In the events worker, Puter stops this loop after 4 runs on
the free plan and 12 on a paid plan, and doesn't report an error.
## Make Sure Every File Gets Handled
By default, the handler runs once per event. If it throws or takes longer than
30 seconds, that event is lost. There's also a limit of 60 runs a minute per
user and app, and runs past it are skipped, so if someone uploads 100 files at
once, only about 60 get recorded.
To handle every file, use `delivery: 'single'`. Puter then keeps each event
until a run finishes without throwing, retrying after 2 seconds, then 4, then
8, up to 5 minutes apart. Events over the per-minute limit are delivered later
instead of being skipped:
```js
options.delivery = 'single';
options.targets = ['worker'];
```
Setting `targets: ['worker']` runs the handler in the events worker right away.
Without it, if the user has your app open, Puter tries those tabs first, which
can delay each event by a minute or two.
Subscriptions keep the delivery they were created with. To replace the
subscriptions users created before this change, `startBackgroundWork()` also
checks `existing.delivery`:
```js
async function startBackgroundWork () {
const folder = await puter.fs.mkdir(inbox, { createMissingParents: true });
const existing = await findSubscription();
const working = existing
&& existing.anchor.uid === folder.uid
&& existing.suspendedReason !== 'permission_revoked'
&& existing.delivery === 'single';
if (working) return;
if (existing) await puter.events.unsubscribe(existing.subId);
await puter.events.onPersistent(options);
}
```
Some things to know about `single`:
- **The same event can run twice.** For example, if a run finishes its work but
times out, Puter tries again with the same `event.id`. Saving the same
key-value record twice leaves the same data, but posting to the chat twice
sends two messages. To prevent most of these, save `event.id` after posting
and check for it before posting. A run that crashes between posting and
saving can still post twice.
- **Stop retrying events that can't succeed.** If retrying won't help, for
example with a file type you don't support, throw an error with
`terminal: true`. Puter drops the event instead of retrying it.
- **Five failures in a row pause the subscription**, including events you
stopped with `terminal: true`. Puter notifies you as the developer. Fix the
handler and publish it again. The subscription resumes and delivers the
events it saved while paused.
- Events with `single` delivery cost the user $1 per million instead of $0.10.
See [Rate Limits and Quotas](/rate-limits-and-quotas/#events).
Here's `recordUpload()` with the duplicate check. The marker key expires after
a week, so these keys don't stay in the user's key-value store. Publish the
handler again after the change:
```js
async function recordUpload ({ event, user, ctx }) {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
const name = event.path.split('/').pop();
await user.kv.set(`uploads:${event.uid}`, { name, addedAt: event.ts });
const postedKey = `posted:${event.id}`;
if (ctx.webhookUrl && !(await user.kv.get(postedKey))) {
await fetch(ctx.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `New file: ${name}` }),
});
await user.kv.set(postedKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}
}
```
## Update the Handler
To change the handler, publish it again under the same name. Every
subscription uses the new code from its next event, and users don't need to do
anything.
A deploy script runs in a new process each time, so its SDK hasn't seen the
published handler yet. Call [`handlers.list()`](/Events/handlers/) before
publishing changed code. Without that call, [`publish()`](/Events/handlers/)
fails with `events_handler_conflict`. This check stops two deploys from
overwriting each other's code without an error:
```js
await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('recordUpload', recordUpload, { appUid: app.uid });
```
After [`handlers.list()`](/Events/handlers/), publishing only fails if someone
else published in between. Pass `replace: true` to overwrite anyway. To roll
back, publish the old code.
Keep the handler's name the same. Subscriptions are tied to the name, so after
a rename every user's app has to subscribe again.
To see your apps' events workers, or remove the ones you no longer need, use
[`puter.events.workers`](/Events/workers/).
## Check on Subscriptions
A paused (*suspended*) subscription still exists, and
[`list()`](/Events/list/) shows why it's paused in `suspendedReason`:
| `suspendedReason` | What happened | What to do |
| --- | --- | --- |
| `handler_not_found` | The handler was removed. | Publish it again. The subscription resumes. |
| `failures` | Five runs in a row failed (`single` only). | Fix the handler and publish it again. The subscription resumes. |
| `no_credit` | The user ran out of credit. | Nothing. It resumes when they add credit. |
| `permission_revoked` | The user took back `events:background`, or lost access to the folder. | Ask for the permission again, then call `startBackgroundWork()`. |
A paused `single` subscription saves up to 100 missed events and delivers them
when it resumes. They're kept for 24 hours, or 1 hour for `no_credit`. Default
subscriptions don't save missed events while paused.
A subscription can also be removed entirely, for example when its folder is
permanently deleted. It then disappears from [`list()`](/Events/list/), and
`startBackgroundWork()` creates a new one the next time the app opens. When
that happens, Puter sends the user's app an `app.events.ended` notification.
When subscriptions are paused, it sends you, the developer,
`app.events.suspended`. [Catch Up on
Notifications](/recipes/catch-up-on-notifications/) shows how to read
both.
To turn off background work, for example from a setting in your app, call
[`puter.events.unsubscribe(subId)`](/Events/unsubscribe/) with the `subId` from
`findSubscription()`.
## Full Code
The recipe has two programs. The publish script runs as the app's developer,
from a deploy script or your browser console:
```js
const app = await puter.apps.get('drop-box'); // your app's name
async function recordUpload ({ event, user, ctx }) {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
const name = event.path.split('/').pop();
await user.kv.set(`uploads:${event.uid}`, { name, addedAt: event.ts });
const postedKey = `posted:${event.id}`;
if (ctx.webhookUrl && !(await user.kv.get(postedKey))) {
await fetch(ctx.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `New file: ${name}` }),
});
await user.kv.set(postedKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}
}
await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('recordUpload', recordUpload, { appUid: app.uid });
```
The app code runs in your app, signed in as the user. Here `settings` is your
app's saved settings for that user:
```js
const inbox = `~/AppData/${puter.appID}/Inbox`;
const options = {
subject: `fs:${inbox}`,
handlerName: 'recordUpload',
delivery: 'single',
targets: ['worker'],
context: { webhookUrl: settings.webhookUrl },
};
async function findSubscription () {
const subs = await puter.events.list();
return subs.find((s) =>
s.subject === options.subject && s.handlerName === options.handlerName);
}
async function startBackgroundWork () {
const folder = await puter.fs.mkdir(inbox, { createMissingParents: true });
const existing = await findSubscription();
const working = existing
&& existing.anchor.uid === folder.uid
&& existing.suspendedReason !== 'permission_revoked'
&& existing.delivery === 'single';
if (working) return;
if (existing) await puter.events.unsubscribe(existing.subId);
await puter.events.onPersistent(options);
}
async function updateWebhook (webhookUrl) {
options.context = { webhookUrl };
const existing = await findSubscription();
if (existing) await puter.events.unsubscribe(existing.subId);
await puter.events.onPersistent(options);
}
const allowed = await puter.perms.request('events:background');
if (allowed) await startBackgroundWork();
```