mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 04:53:39 +00:00
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:
1 parent
0c67499dc2
commit
c7cc64e45c
4 files changed
+964
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
```
|
||||
Reference in new issue
Block a user