feat: event handlers, context, and suspension machine (PUT-1680) (#3683)

This commit is contained in:
Daniel Salazar
2026-09-03 01:39:06 -07:00
committed by GitHub
parent 9626ab9c71
commit 04c00385b9
43 changed files with 5418 additions and 66 deletions
+36 -4
View File
@@ -119,7 +119,7 @@ Emptying a whole store with [`puter.kv.flush()`](/KV/flush/) delivers nothing: n
### Gaps
Every per-event limit truncates the delivery rather than failing anything, and sends a **gap marker** in its place: an event with `op: 'gap'`, a `reason`, and no `uid` or `path`. A gap means something happened that you were not told the details of, so treat it as "re-read what I am watching", never as "nothing changed".
Every per-event limit truncates the delivery rather than failing anything, and sends a **gap marker** in its place: an event with `op: 'gap'`, a `reason`, and no `uid` or `path`. A gap means something happened that you were not told the details of, so treat it as "re-read what I am watching", never as "nothing changed". A persistent subscription that was suspended long enough for its held backlog to lapse gets one too, with `reason: 'suspended_backlog_expired'`.
```js
await puter.events.onLocal('fs:~/Documents', async ({ event }) => {
@@ -128,7 +128,7 @@ await puter.events.onLocal('fs:~/Documents', async ({ event }) => {
});
```
## Subscriptions live with the connection
## Two kinds of subscription
`onLocal()` subscriptions are **session-scoped**: nothing is stored, nothing runs while the page is closed, and the server drops them when the connection goes away. Every subscription this client makes rides one connection, which opens on the first `onLocal()` and closes when the last subscription ends. In a worker that means the subscription lasts as long as the invocation that made it, and no longer.
@@ -140,11 +140,43 @@ const sub = await puter.events.onLocal('fs:~/Documents', handler, {
});
```
[`onPersistent()`](/Events/onPersistent/) subscriptions are **stored against the account**. They keep matching with nothing open, survive every reconnect, and end only when you call [`unsubscribe()`](/Events/unsubscribe/) or their `expiresAt` passes. What runs is a *handler* your app deployed by name:
```js
// Once, at deploy time
await puter.events.handlers.publish('ingestUpload', async ({ event, ctx }) => {
await fetch(ctx.endpoint, { method: 'POST', body: event.path });
}, { appUid });
// Per user, when they opt in
await puter.events.onPersistent({
subject: 'fs:~/inbox',
handlerName: 'ingestUpload',
context: { endpoint: 'https://example.com/ingest' },
});
```
### Handlers cannot close over anything
A handler is deployed, not called: it is serialized with `Function.prototype.toString()` and run later, somewhere else, with nothing around it. A closed-over variable is not discouraged — it is *unrepresentable*. Every identifier a handler names has to be a parameter, something it declares itself, a standard global, or reached through `ctx`; the SDK checks that before the call and rejects with `events_handler_free_variable`, naming what it could not resolve.
Values reach a handler through **`context`**, which is evaluated **once, at subscribe time**, serialized, and delivered to every invocation as a frozen `ctx`. It never re-evaluates: `ctx.endpoint` is whatever the value was when the subscription was created, forever, until it is created again.
**`context` is capped at a hard 4 KB.** These are database rows read on every delivery, and `context` is the one field a developer controls the size of — over the cap the call fails with `events_context_too_large`, client-side, before the request. It is stored in plaintext and read only on the delivery path: [`list()`](/Events/list/) returns its **key names and a content hash**, never its values. For anything larger, store it in a file and put the path in `context`; a wider column is not the upgrade path.
See [`puter.events.handlers`](/Events/handlers/) for the deploy side — publishing, replacing, and what removing a name does to the subscriptions bound to it.
A persistent subscription can also stop without you unsubscribing: its handler was removed, its holder ran out of credit, or the share it was made under was withdrawn. It is then *suspended* rather than deleted, and [`list()`](/Events/list/) reports `suspendedAt` and `suspendedReason`. Everything but a withdrawn grant can resume.
## Limits
Subscriptions per connection, subscribe calls per minute, and how much one event may fan out are all capped — see [Rate Limits and Quotas](/rate-limits-and-quotas/). Deliveries are coalesced over 250 ms per subject, so a multipart upload or a save loop arrives as one event rather than one per write.
Subscriptions per connection, persistent subscriptions per account, published handlers per app, subscribe calls per minute, and how much one event may fan out are all capped — see [Rate Limits and Quotas](/rate-limits-and-quotas/). Deliveries are coalesced over 250 ms per subject, so a multipart upload or a save loop arrives as one event rather than one per write.
## Functions
- **[`puter.events.onLocal()`](/Events/onLocal/)** - Subscribe to a subject for as long as this client is connected
- **[`subscription.off()`](/Events/off/)** - End a subscription
- **[`subscription.off()`](/Events/off/)** - End a session subscription
- **[`puter.events.onPersistent()`](/Events/onPersistent/)** - Subscribe with a subscription that keeps running when your app is closed
- **[`puter.events.list()`](/Events/list/)** - List the persistent subscriptions this caller holds
- **[`puter.events.unsubscribe()`](/Events/unsubscribe/)** - End a persistent subscription
- **[`puter.events.handlers`](/Events/handlers/)** - Publish, list and remove the named handlers a persistent subscription runs
+203
View File
@@ -0,0 +1,203 @@
---
title: puter.events.handlers
description: Publish, list and remove the named handlers a persistent subscription runs.
platforms: [websites, apps, nodejs, workers]
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
A **handler** is a function your app deploys once, under a name, that persistent subscriptions bind to. A name is a label for deployed code, not an event: nothing triggers by name, and a handler runs only when a subscription bound to it has a delivery.
Publishing is a **developer** operation. An app token publishes into its own app; an account session has to name an app it owns with `appUid`. Either way the account must own the app.
```js
await puter.events.handlers.publish('ingestUpload', async ({ event, ctx }) => {
await fetch(ctx.endpoint, { method: 'POST', body: event.path });
}, { appUid });
await puter.events.handlers.list({ appUid }); // [{ name, hash, updatedAt, subscriptions }]
await puter.events.handlers.remove('indexDocument', { appUid });
```
## Handlers cannot close over anything
A handler is serialized with `Function.prototype.toString()` and run later, somewhere else. A closed-over variable is not discouraged — it is **unrepresentable**, because nothing around the function survives the trip.
So every identifier a handler names must be one of: a parameter, something the handler itself declares, a standard global (`fetch`, `JSON`, `Math`, `console`, `URL`, `crypto`, …), or reached through `ctx`. The SDK checks this before the request and rejects with `events_handler_free_variable`, naming the identifier:
```js
const endpoint = 'https://example.com/ingest';
// Rejected: `endpoint` is not a parameter, a local, or a known global.
await puter.events.handlers.publish('ingestUpload', ({ event }) => fetch(endpoint), { appUid });
// Accepted: the value travels with the subscription, not with the code.
await puter.events.handlers.publish('ingestUpload', ({ event, ctx }) => fetch(ctx.endpoint), { appUid });
await puter.events.onPersistent({ subject: 'fs:~/inbox', handlerName: 'ingestUpload', context: { endpoint } });
```
The check is deliberately conservative: anything it cannot resolve is refused with a clear message, rather than accepted and failed on first delivery in production.
## `publish()`
```js
puter.events.handlers.publish(name, handler)
puter.events.handlers.publish(name, handler, options)
```
- `name` (String) (required): The name subscriptions bind to. Letters, digits and `_ . : -`, starting alphanumeric, up to 128 characters. Unique per app, and stable across source changes.
- `handler` (Function | String | Object) (required): A function (serialized with `toString()`), a source string, or `{ file: '~/AppData/…/handler.js' }`. **A file reference resolves now, not at delivery** — the bytes as they are at this call are what gets deployed, so editing the file afterwards changes nothing until you publish again.
- `options.replace` (Boolean): Take the name whatever is published under it.
- `options.appUid` (String): The app to publish into. Required for an account session.
Resolves to `{ name, hash, updatedAt, outcome, resumed }`. `outcome` is `'created'`, `'updated'`, or `'unchanged'` when the same source was already published. `resumed` counts subscriptions this publish brought back out of suspension.
### Two build steps must not silently pick a winner
The source hash is a change detector and an idempotency key: publishing the **same** source again is a no-op. Publishing **different** source is an update — but only from a caller that knows what it is updating.
The SDK remembers the hash it last saw published for each name and sends it as the base. A publish whose base has moved under it — a second build step got there first — is refused with `events_handler_conflict`. Pass `replace: true` to say you mean to take the name regardless.
A client that has never published or listed that name sends no base, so its publish can only create, or be idempotent.
## `publishAll()`
```js
puter.events.handlers.publishAll(handlers)
puter.events.handlers.publishAll(handlers, options)
```
Publishes a set in one call — what a build step has. `handlers` is an array of `{ name, handler, replace? }`, capped at 50 entries and taken in order. An item the server refuses stops the pass, so a deploy never reports success over a half-published set; items before it are published, and the error names where it stopped.
Resolves to an array of the same objects `publish()` returns.
## `list()`
```js
puter.events.handlers.list()
puter.events.handlers.list(options)
```
Resolves to `[{ name, hash, updatedAt, subscriptions }]` for everything the app has published, ordered by name. `subscriptions` counts what is bound to that name, **suspended ones included** — a suspended subscription is still a dependent, and it is the reason removing a name is not just a delete.
**Source is never returned.** It is the app's own code, read only on the delivery path.
## `remove()`
```js
puter.events.handlers.remove(name)
puter.events.handlers.remove(name, options)
```
Resolves to `{ name, removed, suspended }`.
| Situation | What happens |
| --- | --- |
| Nothing is bound to the name | The handler is deleted outright. |
| Subscriptions are bound to it | The handler is deleted **and** every subscription on it is *suspended* with `suspendedReason: 'handler_not_found'` — not deleted. The app's developer is notified. |
**Publishing the name again resumes them.** That is what makes a bad deploy recoverable: the subscriptions keep their ids, their context and their place, and start delivering again on the next publish.
Renaming is publish-new plus remove-old, and subscriptions do **not** follow — that is a re-subscribe, deliberately: silently repointing someone's subscription at different code is exactly what consent is protecting against.
### What a suspension does to the backlog
A suspended subscription stops being delivered to and stops being metered — so it cannot go on holding a full backlog for free. On suspension its undelivered deliveries are trimmed to **100** and given a deadline: **24 hours** for `handler_not_found` and `failures`, **1 hour** for `no_credit`. Past the deadline they are dropped and one `gap` marker with `reason: 'suspended_backlog_expired'` takes their place, so a resumed subscription learns there were events rather than reading the silence as "nothing changed". A subscription suspended by `permission_revoked` has its backlog **purged at once** and never resumes.
## Errors
All four methods reject with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `events_handler_free_variable` | The handler names something it cannot carry. The message names the identifier. |
| `events_handler_invalid` | `handler` is not a function, a source string, or `{ file }`. |
| `events_handler_name_invalid` | The name is empty, too long, or not an addressable identifier. |
| `events_handler_conflict` | Different source is published under this name and the caller did not name it as the base. Pass `replace: true` to take it. |
| `events_handler_app_required` | An account session did not name an app. |
| `events_handler_forbidden` | The caller does not own the app — and an app that is not there answers the same way. |
| `events_handler_too_large` | The serialized handler is over 64 KB. |
| `events_handler_source_invalid` | The handler source is empty. |
| `events_handler_limit` | The app already has the maximum number of published handlers. |
| `too_many_requests` | Over the handler publish/remove budget. |
| `events_disabled` | Events are not enabled on this server. |
## Examples
<strong class="example-title">Publish a handler, bind a subscription to it, then take it away</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
// (1) An app of your own — handlers belong to an app you own
const name = `ingest-${puter.randName()}`;
const app = await puter.apps.create(name, `https://example.com/${name}`);
const appUid = app.uid;
// (2) Publish
const published = await puter.events.handlers.publish(
'ingestUpload',
async ({ event, ctx }) => {
await fetch(ctx.endpoint, { method: 'POST', body: event.path });
},
{ appUid },
);
puter.print(`published ${published.name} (${published.outcome})<br>`);
// (3) Publishing the same source again changes nothing
const again = await puter.events.handlers.publish(
'ingestUpload',
async ({ event, ctx }) => {
await fetch(ctx.endpoint, { method: 'POST', body: event.path });
},
{ appUid },
);
puter.print(`second publish: ${again.outcome}<br>`);
// (4) What is deployed, and how much depends on it
for (const handler of await puter.events.handlers.list({ appUid })) {
puter.print(`${handler.name}: ${handler.subscriptions} subscription(s)<br>`);
}
// (5) Nothing bound to it, so it is deleted outright
const removed = await puter.events.handlers.remove('ingestUpload', { appUid });
puter.print(`removed: ${removed.removed}, suspended: ${removed.suspended}<br>`);
})();
</script>
</body>
</html>
```
<strong class="example-title">Deploy a whole set from a build step</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const name = `pipeline-${puter.randName()}`;
const app = await puter.apps.create(name, `https://example.com/${name}`);
const published = await puter.events.handlers.publishAll([
{
name: 'ingestUpload',
handler: ({ event, ctx }) => fetch(ctx.ingest, { body: event.path }),
},
{
name: 'indexDocument',
handler: ({ event, ctx }) => fetch(ctx.index, { body: event.uid }),
},
], { appUid: app.uid });
for (const handler of published) {
puter.print(`${handler.name} → ${handler.outcome}<br>`);
}
})();
</script>
</body>
</html>
```
+89
View File
@@ -0,0 +1,89 @@
---
title: puter.events.list()
description: List the persistent subscriptions this caller holds.
platforms: [websites, apps, nodejs, workers]
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
Lists the persistent subscriptions created with [`puter.events.onPersistent()`](/Events/onPersistent/). Session subscriptions made with `onLocal()` are not listed — they live with the connection and are not stored anywhere.
An app sees only the subscriptions it created. A session acting for the account sees them all, **including ones left behind by an app that is gone** — which is what makes the account the place a stray subscription is revoked from.
## Syntax
```js
puter.events.list()
puter.events.list(options)
```
## Parameters
#### `options` (Object) (optional)
- `limit` (Number): Maximum subscriptions per request. Capped at 200; defaults to 50.
- `cursor` (String | null): Continuation token from a previous page. Passing it — `null` included — switches the return value to a single page envelope.
- `includeTotal` (Boolean): Adds `total` to the envelope. Request it on the first page only; it costs more the more subscriptions exist.
- `stream` (Boolean): Returns an async iterator of page envelopes instead of a promise.
## Return value
With no pagination params, a `Promise` for an array of every subscription, fetched page by page under the hood. With `cursor` or `includeTotal`, a `Promise` for one page: `{ items, cursor?, total? }` — `cursor` is present only while more pages exist. With `stream: true`, an async iterator of those envelopes.
**Pages may be short.** Never read `items.length < limit` as the end of the list; iterate until `cursor` is absent.
Each subscription is the object [`onPersistent()`](/Events/onPersistent/) returns. In particular:
- `contextKeys` (Array | null) and `contextHash` (String | null) describe the stored `context`. **The values are never returned** — the context is where an API key lives, and a listing is the one surface an app can call repeatedly. The hash changes whenever any value does, which is enough to tell two subscriptions apart or to notice one was re-created.
- `suspendedAt` (Number | null) and `suspendedReason` (String | null) say whether a subscription stopped delivering without being removed, and why: `handler_not_found`, `failures`, `no_credit`, or `permission_revoked`.
The promise rejects with `{ message, code }` — `too_many_requests` over the listing budget, `events_disabled` where events are off.
## Examples
<strong class="example-title">List everything this account is watching</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
context: { label: 'inbox' },
});
for (const row of await puter.events.list()) {
puter.print(`${row.subject} — ${row.delivery}`);
puter.print(` (context: ${row.contextKeys?.join(', ') ?? 'none'})<br>`);
}
await puter.events.unsubscribe(sub.subId);
})();
</script>
</body>
</html>
```
<strong class="example-title">Find the ones that stopped, and why</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
for await (const page of puter.events.list({ stream: true })) {
for (const row of page.items) {
if (!row.suspendedAt) continue;
puter.print(`${row.subject} stopped: ${row.suspendedReason}<br>`);
}
}
puter.print('done<br>');
})();
</script>
</body>
</html>
```
+156
View File
@@ -0,0 +1,156 @@
---
title: puter.events.onPersistent()
description: Subscribe to changes with a subscription that keeps running when your app is closed.
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 is stored against the account, keeps matching while your app is closed, and runs a handler your app published with [`puter.events.handlers.publish()`](/Events/handlers/). Contrast [`puter.events.onLocal()`](/Events/onLocal/), which lives and dies with the page.
See [Events](/Events/) for the subject grammar and the event shape.
## Syntax
```js
puter.events.onPersistent(options)
```
## Parameters
#### `options` (Object) (required)
- `subject` (String) (required): What to watch — the same grammar `onLocal()` takes, e.g. `fs:~/Documents` or `fs:~/inbox/*.json:add`.
- `delivery` (String): `'broadcast'` (default) delivers to everything listening. `'single'` delivers each event to exactly one consumer, which must acknowledge it, and requires `handlerName`.
- `targets` (Array): Transports deliveries may take — any of `'socket'`, `'worker'`, `'push'`. Defaults to `['socket', 'worker']` for a subscription an app made, `['socket']` for one an account session made naming no app. A `single` subscription may not target `'push'`; a subscription with no app may not target `'worker'` — there is exactly one events worker per app, and no app means no worker to invoke.
- `handlerName` (String): The published handler this subscription binds to. Required for `single`.
- `handler` (Function | String | Object): The handler source this subscription was written against. Sent as a **hash**, never as source: the subscription binds only if that hash matches what is published under `handlerName`, which is why `handlerName` is required alongside it. Accepts a function, a source string, or `{ file: '~/AppData/…/handler.js' }`.
- `context` (Object): Values the handler needs, delivered to it as a frozen `ctx`. **Capped at 4 KB serialized** — see below.
- `expiresAt` (Number | String): When the subscription ends by itself — unix seconds or an ISO-8601 string, and it has to be in the future.
## `context` is evaluated once, and capped at 4 KB
A handler is deployed, not called: it is serialized and run later, somewhere else, so it cannot close over anything. `context` is how values reach it — and it is evaluated **at this call**, serialized, and never re-evaluated. `ctx.endpoint` is whatever `process.env.INGEST_URL` was when you subscribed, forever, until you subscribe again.
```js
await puter.events.onPersistent({
subject: 'fs:~/inbox',
handlerName: 'ingestUpload',
context: { endpoint: process.env.INGEST_URL, apiKey: process.env.INGEST_KEY },
});
```
**The cap is a hard 4 KB.** These are database rows read on every delivery, and `context` is the one field you control the size of; over the cap the call fails with `events_context_too_large`, client-side, before the request. Context is stored in plaintext and is read only on the delivery path — [`puter.events.list()`](/Events/list/) returns its **key names and a content hash**, never its values. If you need to hand a handler more than 4 KB, put it in a file and pass the path in `context`; a wider column is not the upgrade path.
## Return value
A `Promise` that resolves to the subscription:
- `subId` (String): Its id, and what [`puter.events.unsubscribe()`](/Events/unsubscribe/) names. Stable for the life of the subscription.
- `subject`, `anchor`, `match`, `op`: as `onLocal()` returns them.
- `delivery` (String), `targets` (Array), `handlerName` (String | null).
- `appUid` (String | null): The app that created it, or `null` for one an account session made.
- `contextKeys` (Array | null), `contextHash` (String | null): the shape of the stored context, never its values.
- `createdAt`, `expiresAt` (Number | null): unix seconds.
- `suspendedAt` (Number | null), `suspendedReason` (String | null): why it stopped delivering without being removed — see [`puter.events.handlers.remove()`](/Events/handlers/).
The promise rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `invalid_subject` | The subject is not a non-empty string, or the server could not parse it. |
| `events_handler_name_required` | An inline `handler` was given with no `handlerName` to publish it under. |
| `events_handler_free_variable` | The handler names something it cannot carry — a closed-over variable. The message names the identifier. |
| `events_handler_invalid` | `handler` is not a function, a source string, or `{ file }`. |
| `events_handler_hash_unavailable` | This environment provides no `crypto.subtle`, so an inline handler cannot be hashed. Publish it first and pass `handlerName` alone. |
| `events_handler_not_found` | No handler is published under `handlerName`. The subscription is **not** created. |
| `events_handler_hash_mismatch` | The published handler is not the source this subscription was written against. |
| `events_handler_required` | `delivery: 'single'` without a `handlerName`. |
| `events_context_too_large` | The serialized `context` is over 4 KB. |
| `events_context_invalid` | `context` is not JSON-serializable. |
| `invalid_targets` | A target outside `socket`/`worker`/`push`, `push` on a `single` subscription, or `worker` on a subscription with no app. |
| `invalid_expires_at` | `expiresAt` is not a future time. |
| `subject_does_not_exist` | The subject is not there, or this account cannot read it. |
| `events_subscription_limit` | This account already holds the maximum number of persistent subscriptions. |
| `too_many_requests` | Over the subscribe/unsubscribe call budget. |
| `events_disabled` | Events are not enabled on this server. |
## Examples
<strong class="example-title">Watch a folder with a handler that keeps running</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
// (1) An app of your own to publish the handler under
const name = `ingest-${puter.randName()}`;
const app = await puter.apps.create(name, `https://example.com/${name}`);
// (2) Publish the handler. It closes over nothing — everything it
// needs arrives as `ctx`.
await puter.events.handlers.publish(
'ingestUpload',
async ({ event, ctx }) => {
await fetch(ctx.endpoint, {
method: 'POST',
body: JSON.stringify({ path: event.path, key: ctx.apiKey }),
});
},
{ appUid: app.uid },
);
// (3) Subscribe. `context` is read now and never again.
const dir = `~/${puter.randName()}`;
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
handlerName: 'ingestUpload',
context: { endpoint: 'https://example.com/ingest', apiKey: 'k-123' },
});
puter.print(`watching ${dir} as ${sub.subId}<br>`);
// (4) It outlives this page. End it explicitly when you are done.
await puter.events.unsubscribe(sub.subId);
})();
</script>
</body>
</html>
```
<strong class="example-title">Bind to the exact source you wrote against</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const name = `pinned-${puter.randName()}`;
const app = await puter.apps.create(name, `https://example.com/${name}`);
const handler = ({ event, ctx }) => console.log(ctx.label, event.path);
await puter.events.handlers.publish('onWrite', handler, { appUid: app.uid });
const dir = `~/${puter.randName()}`;
await puter.fs.mkdir(dir);
// Passing the function sends its hash: if somebody redeployed
// `onWrite` in the meantime, this fails rather than binding you to
// code you never saw.
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
handlerName: 'onWrite',
handler,
context: { label: 'inbox' },
});
puter.print(`bound to ${sub.handlerName}<br>`);
await puter.events.unsubscribe(sub.subId);
})();
</script>
</body>
</html>
```
+66
View File
@@ -0,0 +1,66 @@
---
title: puter.events.unsubscribe()
description: End a persistent subscription.
platforms: [websites, apps, nodejs, workers]
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
Ends a subscription created with [`puter.events.onPersistent()`](/Events/onPersistent/). It stops matching immediately and everything it was still owed goes with it — a backlog held for a subscription nobody can consume is memory, and the paths it names are ones its holder just stopped asking about.
For a session subscription made with [`puter.events.onLocal()`](/Events/onLocal/), use [`subscription.off()`](/Events/off/) instead.
## Syntax
```js
puter.events.unsubscribe(subId)
```
## Parameters
#### `subId` (String) (required)
The `subId` of the subscription to end, as `onPersistent()` returned it or as [`puter.events.list()`](/Events/list/) reports it.
## Return value
A `Promise` that resolves when the subscription is gone.
An id this caller does not hold — one already ended, or one another app created — **reads as absent** rather than refused, so the call cannot be used to find out which subscriptions exist. It rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `subscription_does_not_exist` | No such subscription, or not this caller's. |
| `too_many_requests` | Over the subscribe/unsubscribe call budget. |
| `events_disabled` | Events are not enabled on this server. |
An app may only end the subscriptions it created. A session acting for the account may end any of them, including ones left behind by an app that is gone.
## Examples
<strong class="example-title">Create a persistent subscription, then end it</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({ subject: `fs:${dir}` });
puter.print(`watching as ${sub.subId}<br>`);
await puter.events.unsubscribe(sub.subId);
puter.print('stopped<br>');
// Ending it twice is refused the same way an unknown id is.
try {
await puter.events.unsubscribe(sub.subId);
} catch (error) {
puter.print(`second attempt: ${error.code}<br>`);
}
})();
</script>
</body>
</html>
```
+21 -2
View File
@@ -170,15 +170,34 @@ One write can reach many subscriptions, so events are bounded on both halves: ho
| Deliveries per minute, per subscription | 600 |
| Acknowledgements per minute | 600 |
| Undelivered deliveries per subscription | 10,000 |
| Undelivered deliveries per *suspended* subscription | 100 |
| Suspended subscriptions kept for | 30 days |
| Published handlers per app | 100 |
| Handler source size | 64 KB |
| Handlers per `publishAll` call | 50 |
| Handler publish / remove calls per minute | 60 |
| Handler listings per minute | 120 |
Subscriptions come in two kinds. A **session** subscription lives with the connection that made it: it is dropped when the connection closes, and a reconnecting client subscribes again. A **durable** subscription outlives every connection — it is created over the API, listed and revoked from the account, and keeps delivering until you remove it or it expires.
The 51st subscription on one connection, and the 501st durable subscription on one account, both fail with `events_subscription_limit`. Over the call budget, `subscribe` and `unsubscribe` fail with `too_many_requests`. Subscribing to something you cannot read fails with `subject_does_not_exist` — the same answer as subscribing to something that is not there, so the call cannot be used to find out which.
A durable subscription may carry a `context`: JSON that is stored with it and handed to its handler on every delivery, capped at **4 KB** and rejected over that with `events_context_too_large`. Listings never return it. An app sees and revokes only the subscriptions it created; a session acting for the account sees them all, including ones left behind by an app that has since been removed.
A durable subscription may carry a `context`: JSON that is stored with it and handed to its handler on every delivery, capped at a hard **4 KB** and rejected over that with `events_context_too_large` — client-side, before the request. It is stored in plaintext and read only on the delivery path; listings return its **key names and a content hash**, never its values. For anything larger, store it in a file and put the path in `context`. An app sees and revokes only the subscriptions it created; a session acting for the account sees them all, including ones left behind by an app that has since been removed.
**A subscription can end without you unsubscribing.** Access is re-checked against the stored permission on every delivery, so a share that is taken back stops delivering immediately; the subscription is then *suspended*, with `suspendedAt` and `suspendedReason: 'permission_revoked'` in `list` and a notification to whoever holds it. The same happens to every subscription an app holds for you when you withdraw that app's access. Re-granting does not bring a suspended subscription back — subscribe again, which is how consent to watch is re-established — and a suspended row is deleted **30 days** after it stops. Deleting the node a subscription is anchored on ends it too, unless the subject named a path or a pattern, in which case it follows that path up to the nearest folder that still exists and keeps watching, so recreating the path resumes delivery.
A durable subscription runs a **handler** its app published by name. An app may publish **100** of them, each up to **64 KB** of source, and a name is unique inside one app. Publishing is a developer operation: the account has to own the app. Publishing the same source again is a no-op; publishing different source under a name whose current source the caller did not name as its base is refused with `events_handler_conflict`, so two racing build steps never silently pick a winner — `replace: true` is how a caller says it means to take the name. Handler source is never returned by any listing.
**A subscription can end or stop without you unsubscribing.** Access is re-checked against the stored permission on every delivery, so a share that is taken back stops delivering immediately; the subscription is then *suspended*, with `suspendedAt` and `suspendedReason` in `list`. There are four reasons:
| `suspendedReason` | Cause | Resumes when |
| --- | --- | --- |
| `handler_not_found` | The handler it is bound to was removed | The name is published again |
| `failures` | Its handler failed or timed out repeatedly | The subscription is republished against a working handler |
| `no_credit` | Its holder ran out of credit | The balance is restored |
| `permission_revoked` | The grant it was made under was withdrawn | **Never** — subscribe again |
A suspended subscription stops delivering and stops being metered, so it cannot go on holding a full backlog for free: what it is owed is trimmed to **100** deliveries and given a deadline — **24 hours** for `handler_not_found` and `failures`, **1 hour** for `no_credit` — after which they are dropped and one `gap` marker with `reason: 'suspended_backlog_expired'` takes their place. A subscription suspended by `permission_revoked` has its backlog **purged immediately**: it names paths its holder has just lost the right to see, and holding them for a resume that by design never comes would turn a revocation into a delayed disclosure. A suspended row itself is deleted **30 days** after it stops.
Deleting the node a subscription is anchored on ends it too, unless the subject named a path or a pattern, in which case it follows that path up to the nearest folder that still exists and keeps watching, so recreating the path resumes delivery.
Match patterns are compiled once when you subscribe and are capped at **256 characters** and **16 segments**; anything larger is rejected with `invalid_subject_pattern`. `**` crosses directories and costs no more than `*`.
+32
View File
@@ -422,6 +422,38 @@ let sidebar = [
source: '/Events/off.md',
path: '/Events/off',
},
{
title: '<code>onPersistent()</code>',
page_title: '<code>puter.events.onPersistent()</code>',
title_tag: 'puter.events.onPersistent()',
icon: '/assets/img/function.svg',
source: '/Events/onPersistent.md',
path: '/Events/onPersistent',
},
{
title: '<code>list()</code>',
page_title: '<code>puter.events.list()</code>',
title_tag: 'puter.events.list()',
icon: '/assets/img/function.svg',
source: '/Events/list.md',
path: '/Events/list',
},
{
title: '<code>unsubscribe()</code>',
page_title: '<code>puter.events.unsubscribe()</code>',
title_tag: 'puter.events.unsubscribe()',
icon: '/assets/img/function.svg',
source: '/Events/unsubscribe.md',
path: '/Events/unsubscribe',
},
{
title: '<code>handlers</code>',
page_title: '<code>puter.events.handlers</code>',
title_tag: 'puter.events.handlers',
icon: '/assets/img/function.svg',
source: '/Events/handlers.md',
path: '/Events/handlers',
},
],
},
{