Merge juancastro/put-2172-notifications-settings into juancastro/put-2258-push-from-app-subdomains

This commit is contained in:
Juan Castro committed 2026-10-28 16:30:42 -04:00
commit eeedeb817f
49 files changed
+1366 -952

No files matched your search

+2 -2
View File
@@ -11,7 +11,7 @@ The Events API tells your app when a user's data changes. Subscribe to a file, a
There are two kinds of subscription:
- **Session subscriptions** ([`onLocal()`](/Events/onLocal/)) live on the open page and end when it closes. Use them to keep a UI in sync.
- **Persistent subscriptions** ([`onPersistent()`](/Events/onPersistent/)) are stored on the account and keep running while your app is closed, running a [handler](/Events/handlers/) your app publishes. Use them for background work, such as processing uploads as they arrive.
- **Persistent subscriptions** ([`onPersistent()`](/Events/onPersistent/)) are stored on the account and keep running while your app is closed, running a [handler](/Events/handlers/) your app publishes. Use them for background work, such as processing uploads as they arrive. For work that has to finish, pass `queue: true` and an event whose run never finishes, or fails with a temporary error, is offered again. See [Choosing a mode](/Events/onPersistent/#choosing-a-mode).
What you watch is written as a subject string:
@@ -212,7 +212,7 @@ With the [User-Pays Model](/user-pays-model/), deliveries are billed to the user
});
for (const row of await puter.events.list()) {
puter.print(`${row.subject} — ${row.delivery}`);
puter.print(`${row.subject} — ${row.queue ? 'queue' : 'every listener'}`);
puter.print(` (context: ${row.contextKeys?.join(', ') ?? 'none'})<br>`);
}
+7 -9
View File
@@ -102,22 +102,20 @@ Renaming is publishing the new name and removing the old one. Subscriptions don'
An app's first published handler creates its [events worker](/Events/workers/), and removing the last one takes it down.
### Refusing a delivery
### Retrying a delivery
In the events worker, a handler that returns takes the delivery, and one that throws is retried later. When retrying can't help (a malformed event, say), throw an error with `terminal: true` or `code: 'events_terminal'`. The delivery is dropped and replaced by a [gap marker](/Events/subjects/#gaps) with `reason: 'handler_rejected'`.
A handler that returns takes the delivery. On a `queue` subscription, a handler that throws drops the event, and it's replaced by a [gap marker](/Events/subjects/#gaps) with `reason: 'handler_rejected'`. Network errors, timeouts, rate limits and `5xx` errors are retried. To have any other error retried, throw it with `retry: true`:
```js
await puter.events.handlers.publish('ingestUpload', async ({ event }) => {
if (! event.path.endsWith('.json')) {
const err = new Error(`cannot ingest ${event.path}`);
err.terminal = true;
throw err;
await puter.events.handlers.publish('ingestUpload', async ({ event, ctx }) => {
const res = await fetch(ctx.endpoint, { method: 'POST', body: event.path });
if (res.status === 409) {
throw Object.assign(new Error('ingest busy'), { retry: true });
}
// ...
}, { appUid });
```
See [`onPersistent()`](/Events/onPersistent/#where-the-handler-runs) for the full list of outcomes, and [suspended subscriptions](/Events/onPersistent/#suspended-subscriptions) for what happens to a suspended subscription's backlog.
See [what happens when a handler fails](/Events/onPersistent/#what-happens-when-a-handler-fails) for the full list of outcomes, and [suspended subscriptions](/Events/onPersistent/#suspended-subscriptions) for what happens to a suspended subscription's backlog.
## Errors
+1 -2
View File
@@ -39,7 +39,6 @@ Each subscription is the object [`onPersistent()`](/Events/onPersistent/) return
- `contextKeys` (Array | null) and `contextHash` (String | null) describe the stored `context`. **Values are never returned**, since context often holds secrets. The hash changes whenever a value does.
- `suspendedAt` (Number | null) and `suspendedReason` (String | null) are set when a subscription is [suspended](/Events/onPersistent/#suspended-subscriptions): `handler_not_found`, `failures`, `no_credit`, or `permission_revoked`.
- `targets` (Array) can include `'push'`, which is accepted but delivers nothing yet.
The promise rejects with `{ message, code }`: `auth_canceled` if nobody was signed in and the user closed the sign-in, `too_many_requests` over the listing rate limit, `events_disabled` where events are off, and `events_failed` for a response the SDK couldn't read.
@@ -61,7 +60,7 @@ The promise rejects with `{ message, code }`: `auth_canceled` if nobody was sign
});
for (const row of await puter.events.list()) {
puter.print(`${row.subject} — ${row.delivery}`);
puter.print(`${row.subject} — ${row.queue ? 'queue' : 'every listener'}`);
puter.print(` (context: ${row.contextKeys?.join(', ') ?? 'none'})<br>`);
}
+57 -36
View File
@@ -6,7 +6,7 @@ platforms: [websites, apps, nodejs, workers]
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
Creates a subscription that outlives this connection. It's stored on the account, keeps matching while your app is closed, and runs a handler your app published with [`puter.events.handlers.publish()`](/Events/handlers/). Compare [`puter.events.onLocal()`](/Events/onLocal/), which ends with the page.
Creates a subscription that outlives this connection. It's stored on the account, keeps matching while your app is closed, and runs a handler your app published with [`puter.events.handlers.publish()`](/Events/handlers/). Each event also reaches every connected client. Compare [`puter.events.onLocal()`](/Events/onLocal/), which ends with the page.
The subscription is live immediately in the region where it was created. Changes made in other regions can take a moment longer (usually well under a second) to reach it right after this call resolves.
@@ -24,36 +24,52 @@ puter.events.onPersistent(options)
#### `options` (Object) (required)
- `subject` (String) (required): What to watch, in the same form `onLocal()` takes, e.g. `fs:~/Documents` or `fs:~/inbox/*.json:add`.
- `delivery` (String): `'broadcast'` (default) delivers to every listener. `'single'` delivers each event to exactly one consumer, which must acknowledge it, and requires `handlerName`.
- `targets` (Array): Where deliveries may go: any of `'socket'`, `'worker'`, `'push'`. Defaults to `['socket', 'worker']` when an app subscribes and `['socket']` when an account session with no app does. A subscription with no app can't target `'worker'`, because the [events worker](/Events/workers/) belongs to an app. `'push'` is reserved for future device notifications: it's accepted (except with `single`) but delivers nothing yet.
- `handlerName` (String): The published handler to run. Required for `single`.
- `handler` (Function | String | Object): The handler source you wrote this subscription against: a function, a source string, or `{ file: '~/AppData/…/handler.js' }`. Only its **hash** is sent. The subscription is created only if it matches what's published under `handlerName`, so `handlerName` is required with it. Passing a function also runs it in this client, on the deliveries the server leaves to clients (see below).
- `queue` (Boolean): Handles events like a work queue. Each event goes to one consumer at a time, and it's offered again if a run never finishes or hits a temporary error. Requires `handlerName`. Defaults to `false`. See [Choosing a mode](#choosing-a-mode).
- `delivery` (String): Deprecated, use `queue` instead. A value of `'single'` is the same as `queue: true`, and `'broadcast'` is the default.
- `targets` (Array): Deprecated and ignored.
- `handlerName` (String): The published handler to run in your app's [events worker](/Events/workers/). Required for `queue`. A subscription with no app has no events worker, so its handler runs only in connected clients.
- `handler` (Function | String | Object): The handler source you wrote this subscription against: a function, a source string, or `{ file: '~/AppData/…/handler.js' }`. Only its **hash** is sent. The subscription is created only if it matches what's published under `handlerName`, so `handlerName` is required with it. Passing a function also runs it in this client for each delivery it receives (see below).
- `context` (Object): Values the handler needs, passed to it as a frozen `ctx`. **Up to 4 KB serialized**; see below.
- `expiresAt` (Number | String): When the subscription ends on its own, as unix seconds or an ISO-8601 string. Must be in the future.
- `includeValue` (Boolean): For a `kv:` subject, include the key's new value as `event.value` (`null` on a `del`, absent on an `expire`). The value is left out when it's over 16 KB, when more than 128 subscriptions match the change in that region, or when the filter-check limit stops the count early. Refused on other subjects.
- `onError` (Function): Called with `{ message, code }` when this client stops running `handler` because its connection can't be restored (`reauth_required` after a sign-out, `events_connection_failed` otherwise). The subscription itself keeps going (in the events worker, if it targets `worker`), and resumes taking deliveries here once this client reconnects. Only used with a function `handler`; without it, the stop is logged to the console.
- `onError` (Function): Called with `{ message, code }` when this client stops running `handler` because its connection can't be restored (`reauth_required` after a sign-out, `events_connection_failed` otherwise). The subscription itself keeps going in the events worker, and resumes taking deliveries here once this client reconnects. Only used with a function `handler`; without it, the stop is logged to the console.
## Background delivery needs consent
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`. To ask for it, call [`puter.perms.request()`](/Perms/request/) with the `'backgroundEvents'` resource:
Running your handler while the user isn't there needs its own per-app permission, **`events:background`**. When an app subscribes with a `handlerName` without it, the call fails with `events_background_consent_required`. To ask for it, call [`puter.perms.request()`](/Perms/request/) with the `'backgroundEvents'` resource:
```js
await puter.perms.request('backgroundEvents');
```
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']`.
The user can revoke it wherever they manage the app's access. That suspends every subscription with a `handlerName` the app holds for them with `permission_revoked`, and granting it again doesn't resume them: subscribe again. A subscription without a `handlerName` runs nothing in the background and needs no consent.
Background handlers run as a worker session for your app, which appears in the user's sessions list. Revoking that session, withdrawing `events:background`, uninstalling the app, destroying its events worker, or deleting the app all stop background deliveries and invalidate the session's token.
## Choosing a mode
| | Who gets each event | If a run fails |
|---|---|---|
| [`onLocal()`](/Events/onLocal/) | Clients connected right now | Gone. Nothing is kept while disconnected. |
| `onPersistent()` | The events worker, even with the app closed, plus every connected client | Gone. Each place gets one attempt. |
| `onPersistent({ queue: true })` | One consumer at a time: a connected client first, then the events worker | Offered again only if the run never finished or hit a network or other temporary error. |
- **[`onLocal()`](/Events/onLocal/)** is live and best effort. Use it to update the page while it's open.
- **`onPersistent()`** runs your handler in the events worker for every event, even when your app is closed, and also sends the event to every connected client. Each place gets one attempt, and a failed run isn't retried. Use it to react to every change, such as keeping an index or a notification up to date.
- **`onPersistent({ queue: true })`** hands each event to one consumer at a time and retries it when the run never finished or failed for a temporary reason. Use it for work that has to finish, such as processing every uploaded file or calling a webhook.
A queued event comes back only on a retry or a lost answer, with the same `event.id`, so check that ID before doing work twice. Open tabs that passed a function `handler` all receive it, like a broadcast.
## Where the handler runs
A connected client always *receives* the event, but doesn't always run the handler for it. A `broadcast` delivery runs in the events worker or in the connected clients, never both:
Without `queue`, every event runs the handler in each place that's listening:
| Delivery | Runs |
| --- | --- |
| `broadcast`, default `targets` (`['socket', 'worker']`) | The app's [events worker](/Events/workers/). Connected clients run it instead only when the worker can't take it: no worker runtime on this deployment, or the [handler-run limit](/rate-limits-and-quotas/#events) for this user and app is used up for the minute. A run that fails in the worker isn't retried in a client. |
| `broadcast`, `targets: ['socket']` | Every connected client. Nothing runs it otherwise. |
| `single` | Connected clients first; the worker once sockets are spent. It's offered again until acknowledged (see [below](#acknowledging-a-single-delivery)). |
- The app's [events worker](/Events/workers/) runs the handler named by `handlerName`, whether or not a client is open.
- Every connected client that passed a function `handler` runs it too. Every [`onLocal()`](/Events/onLocal/) subscription that matches the change gets the event as well.
The events worker skips a run when the deployment has no worker runtime or the [handler-run limit](/rate-limits-and-quotas/#events) for this user and app is used up for the minute. Connected clients still run it.
With `queue: true`, a connected client that passed a function `handler` gets the first try. If no client has one, or its run fails, the events worker takes the event right away. A client that doesn't answer within 15 seconds loses the event to the events worker, so keep client handlers short. If the user has your app open in several tabs, each tab that passed a function `handler` gets the event, as a broadcast; keep side effects that must happen once in the worker handler.
Whichever place runs it, it's the same code, called with:
@@ -63,7 +79,7 @@ Whichever place runs it, it's the same code, called with:
| `ctx` | The frozen `context` this subscription was created with. |
| `user` | A `puter` bound to the account holding the subscription, with the same access your app has for that user in a tab. In a client, it's the ambient `puter`. |
| `fetch` | [`puter.net.fetch`](/Networking/fetch/) where it exists, otherwise the environment's `fetch`. |
| `ack` | On a `single` subscription only; see below. |
| `ack` | On a `queue` subscription only. Optional; see [below](#what-happens-when-a-handler-fails). |
Only a **function** `handler` runs in this client. A source string or `{ file }` is only used for its hash. The connection reconnects on its own when it drops, so this client keeps taking deliveries unless `onError` is called.
@@ -71,27 +87,32 @@ Those five bindings are the handler's whole environment. The events worker has n
### Handlers that trigger handlers
In the events worker, a write made through `user`, or through a token `user` creates, is one run deeper than the event the handler ran for, and it can run handlers of its own, including this one. `user.workers.create` is refused instead — a handler cannot spin up a worker of its own to escape the chain. A chain stops at **12 runs on a paid plan and 4 on a free one**, by the plan of the account holding the subscription. An event past that runs no handler in the events worker. A `broadcast` one still reaches connected clients without running the handler there; a `single` one is still offered to a connected client first and runs there. The dropped run leaves no gap marker and doesn't count as a failure. Writes made anywhere else, including from a handler running in a client, start a new chain. See [handler chains](/rate-limits-and-quotas/#handler-chains).
In the events worker, a write made through `user`, or through a token `user` creates, is one run deeper than the event the handler ran for, and it can run handlers of its own, including this one. `user.workers.create` is refused instead — a handler cannot spin up a worker of its own to escape the chain. A chain stops at **12 runs on a paid plan and 4 on a free one**, by the plan of the account holding the subscription. An event past that runs no handler in the events worker. Without `queue`, it still reaches connected clients without running the handler there. A queued one is still offered to a connected client first and runs there. The dropped run leaves no gap marker and doesn't count as a failure. Writes made anywhere else, including from a handler running in a client, start a new chain. See [handler chains](/rate-limits-and-quotas/#handler-chains).
`user` in the events worker is valid for 15 minutes, so don't keep it past the run.
### Acknowledging a `single` delivery
### What happens when a handler fails
A `single` delivery stays owed until it's acknowledged:
These rules apply to a `queue` subscription, in a client or in the events worker:
- Calling `ack()` takes the delivery.
- Returning **without** calling `ack()` also takes it.
- **Throwing takes nothing.** After 60 seconds the delivery is offered again, so a handler that throws sees the same event again. `event.id` stays the same across redeliveries; use it to skip work you've already done.
In the events worker, the worker's response decides:
| Response | Result |
| The handler | Result |
| --- | --- |
| `2xx`: the handler returned | Delivery taken. |
| `4xx`: the handler threw an error with `terminal: true` or `code: 'events_terminal'` | Delivery dropped and replaced by a gap marker with `reason: 'handler_rejected'`. |
| `5xx` (any other thrown error), `429`, or no answer within 30 seconds | Retried after 2 seconds, doubling each time up to 5 minutes. |
| Resolves | Done. |
| Throws | The event is dropped and replaced by a [gap marker](/Events/subjects/#gaps) with `reason: 'handler_rejected'`. It counts as a failure. |
| Throws a temporary error: a network error, a timeout, a rate limit (`429`), a `5xx`, or any error with `retry: true` | Retried after 2 seconds, then 4, 8 and so on, up to 5 minutes apart. It counts as a failure. |
| Never answers (the tab closed, the worker crashed, or it ran past 30 seconds in the worker) | Offered again. |
**Five failures in a row, refusals included, suspend the subscription** with `failures`. The developer is notified, and publishing the handler again resumes it. A terminal error only matters in the events worker; thrown in a client, it just rejects like any other error.
To have an error retried, throw it with `retry: true`:
```js
throw Object.assign(new Error('Service busy, try later'), { retry: true });
```
**Five failures in a row suspend the subscription** with `failures`. The developer is notified, and publishing the handler again resumes it.
Without `queue`, a failed run is never retried.
Advanced: a handler can call `ack()` to mark the event done before it finishes. A throw after that doesn't undo it.
## `context` is captured once, up to 4 KB
@@ -122,9 +143,10 @@ A suspended subscription stops delivering and isn't billed. Its backlog is cut t
## Delivery guarantees
Clients connect to the nearest region. Events reach a client wherever it's connected, and `ack()` works on any connection.
Clients connect to the nearest region. Events reach a client wherever it's connected.
- `single` deliveries are **at-least-once**: one may arrive twice, with the same `event.id`. Make handlers safe to run twice.
- Without `queue`, an event runs the handler in the events worker and in each connected client that passed a function `handler`. A run that fails or happens while nothing is connected isn't retried.
- `queue` deliveries are **at-least-once**: an event whose run never finished, or failed with a temporary error, arrives again with the same `event.id`.
- Undelivered events are held in the region where the change happened. If that region goes down, only what it was holding is lost.
- Order is kept within a region and is best-effort across regions. Coalescing (250 ms) also runs per region, so two quick writes can arrive as one event nearby and as two farther away.
@@ -135,7 +157,7 @@ A `Promise` that resolves to the subscription:
- `subId` (String): Its id, which [`puter.events.unsubscribe()`](/Events/unsubscribe/) takes. It never changes.
- `subject` (String): For `kv:` and `notif:`, the subject in full form: `kv:cart` comes back as `kv:<appId>:cart`. For `fs:`, as you passed it, except a relative path comes back resolved the same way [`onLocal()`](/Events/onLocal/) returns it.
- `anchor`, `match`, `op`: as `onLocal()` returns them.
- `delivery` (String), `targets` (Array), `handlerName` (String | null), `includeValue` (Boolean).
- `queue` (Boolean), `handlerName` (String | null), `includeValue` (Boolean).
- `appUid` (String | null): The app that created it, or `null` if an account session did.
- `contextKeys` (Array | null), `contextHash` (String | null): the key names and a hash of the stored context, never its values.
- `createdAt`, `expiresAt` (Number | null): unix seconds.
@@ -154,12 +176,11 @@ The promise rejects with `{ message, code }`:
| `events_handler_hash_unavailable` | This environment has no `crypto.subtle`, so `handler` can't be hashed. Pass `handlerName` alone. |
| `events_handler_not_found` | Nothing is published under `handlerName`. The subscription isn't created. |
| `events_handler_hash_mismatch` | The published handler isn't the source you passed as `handler`. |
| `events_handler_required` | `delivery: 'single'` without a `handlerName`. |
| `events_background_consent_required` | The subscription targets `worker` and the user hasn't granted `events:background`. |
| `events_handler_required` | `queue: true` without a `handlerName`. |
| `events_background_consent_required` | An app subscribed with a `handlerName` and the user hasn't granted `events:background`. |
| `events_context_too_large` | The serialized `context` is over 4 KB. |
| `events_context_invalid` | `context` is not JSON-serializable. |
| `invalid_targets` | `targets` is empty or not an array, it has an unknown target, it has `push` with `single`, an app's `single` subscription leaves out `worker`, or it has `worker` with no app. |
| `bad_request` | `delivery` isn't `'broadcast'` or `'single'`, or `handlerName` isn't a string or is over 128 characters. |
| `bad_request` | `queue` isn't a boolean, `delivery` isn't `'broadcast'` or `'single'`, the two disagree, or `handlerName` isn't a string or is over 128 characters. |
| `invalid_expires_at` | `expiresAt` is not a future time. |
| `invalid_include_value` | `includeValue` isn't a boolean, or the subject isn't `kv:`. |
| `subject_does_not_exist` | The subject doesn't exist, or this account can't read it. |
+1 -1
View File
@@ -198,7 +198,7 @@ Limits never make a subscription fail. Instead they send a **gap marker** in pla
| `filter_evaluation_limit` | The event had more subscription filters to check than one event may check. |
| `delivery_rate_limit` | This subscription went over its per-minute delivery rate. |
| `backlog_overflow` | A persistent subscription's undelivered backlog was full, so the oldest deliveries were dropped. |
| `handler_rejected` | A persistent subscription's handler refused the delivery (a `4xx`, or a thrown terminal error). |
| `handler_rejected` | A `queue` subscription's handler threw an error that isn't retried. |
| `suspended_backlog_expired` | A persistent subscription stayed [suspended](/Events/onPersistent/#suspended-subscriptions) past the time its backlog is kept. |
| `reconnect` | The connection dropped and came back. Changes made while it was down weren't delivered. Sent once, after the subscription is restored. |
+1 -1
View File
@@ -6,7 +6,7 @@ platforms: [websites, apps, nodejs, workers]
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
An **events worker** runs an app's published [handlers](/Events/handlers/): every `broadcast` delivery that targets it, whether or not a client is connected, and a `single` delivery once no connected client takes it. Each app has at most one, created when it publishes its first handler, however many handlers it publishes.
An **events worker** runs an app's published [handlers](/Events/handlers/): every event for a subscription with a `handlerName`, whether or not a client is connected, and a [`queue`](/Events/onPersistent/#choosing-a-mode) delivery once no connected client takes it. Each app has at most one, created when it publishes its first handler, however many handlers it publishes.
A hosted Puter deployment may bill each app's events worker as a monthly cost, even if nothing ever delivers to it. Use this API to see which apps have one and remove the ones you don't need.
@@ -11,7 +11,7 @@
});
for (const row of await puter.events.list()) {
puter.print(`${row.subject} — ${row.delivery}`);
puter.print(`${row.subject} — ${row.queue ? 'queue' : 'every listener'}`);
puter.print(` (context: ${row.contextKeys?.join(', ') ?? 'none'})<br>`);
}
+7 -7
View File
@@ -509,17 +509,17 @@ Unsubscribing counts toward its own `unsubscribe` limit, not the `subscribe` one
| Limit | Value | Scope |
| ---------------------------------------- | ---------- | ---------------------- |
| Broadcast deliveries | 600/min | Per subscription |
| `single` deliveries | 120/min | Per subscription |
| Deliveries without `queue` | 600/min | Per subscription |
| `queue` deliveries | 120/min | Per subscription |
| Handler runs | 60/min | Per user, per app |
| Coalescing window | 250 ms | Per subscription and subject |
| Time to acknowledge a `single` delivery | 60 seconds | Per delivery |
| Time for a client to answer a `queue` delivery | 15 seconds | Per delivery |
| Undelivered backlog | 10,000 | Per subscription |
| Undelivered backlog | 1,000,000 | Per region |
| KV value inlined in a delivery | 16 KB | Per delivery |
- Over a delivery rate, the event is replaced by a gap marker (`delivery_rate_limit`).
- Over the handler-run rate, a `single` delivery waits and goes out later, and a `broadcast` one runs only in connected clients. Neither counts as a handler failure.
- Over the handler-run rate, a `queue` delivery waits and goes out later, and any other delivery runs only in connected clients. Neither counts as a handler failure.
- When a backlog is full, the oldest deliveries are dropped and replaced by one gap marker (`backlog_overflow`).
#### Handlers
@@ -532,7 +532,7 @@ Unsubscribing counts toward its own `unsubscribe` limit, not the `subscribe` one
| Handlers per `publishAll()` call | 50 | Per call |
| Events worker deploys | 30/hour | Per app, all users |
| Run timeout | 30 seconds | Per run |
| Retry delay | 2 seconds, doubling, up to 5 minutes | Per delivery |
| Retry delay (`queue`, temporary errors) | 2 seconds, doubling, up to 5 minutes | Per delivery |
| Failures in a row before suspension | 5 | Per subscription |
| `user` token lifetime in the events worker | 15 minutes | Per run |
@@ -547,7 +547,7 @@ Over the deploy limit, deliveries stay queued and retry after the hour rolls ove
| Handler runs in one chain | Per account holding the subscription | 12 | 4 |
- A write made through a handler's `user` in the events worker, or through a token `user` creates, is one run deeper than the event that ran the handler. Writes from anywhere else start a new chain. `user.workers.create` is refused rather than counted — a handler cannot start an untracked chain through a worker of its own.
- When an event reaches the limit, the events worker runs no handler for it. A `broadcast` one still reaches connected clients without running the persistent handler; a `single` one is still offered to a connected client first and runs there. The dropped run leaves no gap marker and doesn't count as a handler failure.
- When an event reaches the limit, the events worker runs no handler for it. Without `queue`, it still reaches connected clients without running the persistent handler. A queued one is still offered to a connected client first and runs there. The dropped run leaves no gap marker and doesn't count as a handler failure.
- If the holder's plan can't be looked up, the free number applies. A server with no metering uses the paid number.
#### Suspended subscriptions
@@ -580,7 +580,7 @@ Deliveries are billed to the account holding the subscription:
| `events:delivery:broadcast` | 10 µ¢ ($0.10 per million) | Delivered event |
| `events:delivery:single` | 100 µ¢ ($1 per million) | Delivered event |
µ¢ is a microcent (a millionth of a cent). Session subscriptions bill at the broadcast rate. Handler runs bill separately as worker usage.
µ¢ is a microcent (a millionth of a cent). Deliveries for `queue` subscriptions bill on the `single` line, and every other delivery, session subscriptions included, bills at the broadcast rate. Handler runs bill separately as worker usage.
Free: idle subscriptions, events a filter excluded, writes merged by coalescing, deliveries stopped by a permission check, deliveries with no client connected to receive them and no handler run, and gap markers.
+2 -1
View File
@@ -141,7 +141,8 @@ const older = await room.latest('msg:*', { cursor: page.cursor });
## Get New Messages Live
Subscribe to the room's messages with the helper's `watch()`, which uses
[`puter.events.onLocal()`](/Events/onLocal/):
[`puter.events.onLocal()`](/Events/onLocal/). New messages only need to reach
people with the chat open, so a subscription that ends with the page is enough:
```js
await room.watch('msg:*', (event) => {
@@ -23,7 +23,8 @@ as a toast or a row in a panel.
## Show New Notifications
To show notifications as they arrive, subscribe to `notif:app-user` with
[`puter.events.onLocal()`](/Events/onLocal/):
[`puter.events.onLocal()`](/Events/onLocal/). It only updates the open page, and
the catch-up below covers anything missed while the page was closed:
```js
const sub = await puter.events.onLocal('notif:app-user', ({ event }) => {
@@ -12,7 +12,8 @@ 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.
the file changes. The editor only cares while the file is open, so a
subscription that ends with the page is the right fit.
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
@@ -154,7 +154,8 @@ async function startBackgroundWork () {
```
Call `startBackgroundWork()` when the app opens, after the user has given
permission. A few details:
permission. This uses the default mode, which runs the handler for every change
even while the app is closed, and tries each event once. 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
@@ -244,28 +245,27 @@ 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
By default, the events worker runs the handler 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:
To handle every file, use `queue: true`. Puter then treats the subscription
like a work queue: an event whose run never finishes, or fails with a network
error, timeout or other temporary error, is retried 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'];
options.queue = true;
```
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.
A tab of your app gets the event first only if it subscribed with a function
`handler`. Otherwise the events worker runs it right away.
Subscriptions keep the delivery they were created with. To replace the
subscriptions users created before this change, `startBackgroundWork()` also
checks `existing.delivery`:
checks `existing.queue`:
```js
async function startBackgroundWork () {
@@ -275,7 +275,7 @@ async function startBackgroundWork () {
const working = existing
&& existing.anchor.uid === folder.uid
&& existing.suspendedReason !== 'permission_revoked'
&& existing.delivery === 'single';
&& existing.queue;
if (working) return;
if (existing) await puter.events.unsubscribe(existing.subId);
@@ -283,22 +283,19 @@ async function startBackgroundWork () {
}
```
Some things to know about `single`:
Some things to know about `queue`:
- **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
- **A retried event has the same `event.id`.** If a run does its work but
times out before finishing, the retry repeats it. For work like posting a
message, save `event.id` afterwards and skip IDs you've already handled.
- **A thrown error isn't retried.** The event is dropped. To have it tried
again, throw it with `retry: true`, for example when a service you call is
busy.
- **Five failures in a row pause the subscription**, retried and dropped
events alike. 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.
- Events delivered with `queue` 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
@@ -360,11 +357,11 @@ A paused (*suspended*) subscription still exists, and
| `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. |
| `failures` | Five runs in a row failed (`queue` 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
A paused `queue` 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.
@@ -419,8 +416,7 @@ const inbox = `~/AppData/${puter.appID}/Inbox`;
const options = {
subject: `fs:${inbox}`,
handlerName: 'recordUpload',
delivery: 'single',
targets: ['worker'],
queue: true,
context: { webhookUrl: settings.webhookUrl },
};
@@ -437,7 +433,7 @@ async function startBackgroundWork () {
const working = existing
&& existing.anchor.uid === folder.uid
&& existing.suspendedReason !== 'permission_revoked'
&& existing.delivery === 'single';
&& existing.queue;
if (working) return;
if (existing) await puter.events.unsubscribe(existing.subId);
@@ -54,7 +54,8 @@ await puter.kv.set(`${prefix}score`, { home: 1, away: 0 });
The fan finds the share with [`puter.kv.listShared()`](/KV/listShared/) and
subscribes to `kv:<handle>:*` with [`puter.events.onLocal()`](/Events/onLocal/).
Pass [`includeValue: true`](/Events/subjects/#getting-the-new-value) to get the new value with each event:
The fan only needs updates while their page is open, so nothing has to run in
the background. Pass [`includeValue: true`](/Events/subjects/#getting-the-new-value) to get the new value with each event:
```js
const { items } = await puter.kv.listShared();
@@ -178,7 +178,8 @@ described in [Key-value data](/Events/subjects/#key-value-data).
The `watch()` part of the helper calls
[`puter.events.onLocal()`](/Events/onLocal/), so each person sees the other's
changes as they happen:
changes as they happen while the list is open. The list itself is saved, so
nothing is lost when nobody is watching:
```js
await alicesList.watch('item:*', (event) => {
+3 -1
View File
@@ -21,7 +21,9 @@ the handler. It also runs for changes made somewhere else:
- Another person writes to a folder shared with the user.
To subscribe, use the [`puter.events.onLocal()`](/Events/onLocal/) method. The
subscription lasts until you end it or the user closes the tab.
subscription lasts until you end it or the user closes the tab, which is what a
page that only updates what it shows needs. To run code while your app is
closed, see [Run Code in the Background](/recipes/run-code-in-the-background/).
## Watch a Key