mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-10 23:36:06 +00:00
fix: withdrawing background consent revokes the app's events session (#3777)
* fix: withdrawing background consent revokes the app's events session A background handler runs as a worker session for the subscriber and app. Revoking `events:background` or uninstalling the app suspended the subscriptions but left that session valid, so a token a handler had copied out kept working until the user found the row in the sessions list. The revocation settle now revokes the session too; the next consented delivery mints a fresh one. * fix: cleanup docs
This commit is contained in:
@@ -3759,6 +3759,19 @@ export class EventsService extends PuterService {
|
||||
async settleRevokedGrant(revocation: RevokedGrant): Promise<number> {
|
||||
if (!this.enabled) return 0;
|
||||
|
||||
// A copied-out worker token must stop working the moment the consent
|
||||
// that let it be minted is gone — not just the rows using it.
|
||||
if (
|
||||
revocation.appUid &&
|
||||
(revocation.permission === null ||
|
||||
revocation.permission === EVENTS_BACKGROUND_PERMISSION)
|
||||
) {
|
||||
await this.#revokeWorkerSession(
|
||||
revocation.holderUserId,
|
||||
revocation.appUid,
|
||||
);
|
||||
}
|
||||
|
||||
const held = await this.stores.durableSubscription.listActiveForHolder(
|
||||
revocation.holderUserId,
|
||||
revocation.appUid,
|
||||
@@ -3775,6 +3788,27 @@ export class EventsService extends PuterService {
|
||||
return suspended.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the reused `events:handlers` worker session for (holder, app), if
|
||||
* one was ever minted. Best-effort: a lookup or revoke failure must not
|
||||
* fail the settle that triggered it.
|
||||
*/
|
||||
async #revokeWorkerSession(userId: number, appUid: string): Promise<void> {
|
||||
try {
|
||||
const row = await this.stores.session.getWorker(userId, {
|
||||
appUid,
|
||||
workerName: EVENTS_WORKER_SESSION_NAME,
|
||||
});
|
||||
if (row) await this.services.auth.revokeSession(row.uuid);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[events] could not revoke worker session',
|
||||
appUid,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of a holder's rows one withdrawn grant actually stops.
|
||||
*
|
||||
|
||||
@@ -45,11 +45,16 @@ import {
|
||||
EVENTS_CONSECUTIVE_FAILURES,
|
||||
deliveryBackoffMs,
|
||||
} from '../../controllers/events/limits.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { EVENTS_BACKGROUND_PERMISSION } from './authorization.js';
|
||||
import { eventsInvokeKey, eventsWorkerScript } from './workerRuntime.js';
|
||||
import {
|
||||
EVENTS_WORKER_SESSION_NAME,
|
||||
eventsInvokeKey,
|
||||
eventsWorkerScript,
|
||||
} from './workerRuntime.js';
|
||||
import { handlerSetHash } from './workerSource.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
@@ -125,12 +130,12 @@ const startStub = async (): Promise<string> => {
|
||||
return `http://127.0.0.1:${(stub.address() as AddressInfo).port}`;
|
||||
};
|
||||
|
||||
const subscribe = async (): Promise<string> => {
|
||||
const subscribe = async (token: string = appToken): Promise<string> => {
|
||||
const response = await fetch(new URL('/events/subscribe', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${appToken}`,
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subject: `fs:${anchor}`,
|
||||
@@ -145,6 +150,59 @@ const subscribe = async (): Promise<string> => {
|
||||
return body.subId;
|
||||
};
|
||||
|
||||
/**
|
||||
* A second app, isolated from the shared fixture, for tests that revoke or
|
||||
* uninstall — so they don't take the rest of the suite's app down with them.
|
||||
*/
|
||||
const makeWorkerApp = async (): Promise<{
|
||||
appUid: string;
|
||||
appToken: string;
|
||||
actor: Actor;
|
||||
}> => {
|
||||
const uid = `app-${uuidv4()}`;
|
||||
await env.server.clients.db.write(
|
||||
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
|
||||
[uid, uid, uid, `https://${uid}.example/`, userId],
|
||||
);
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
env.users.user.token,
|
||||
);
|
||||
await env.server.services.permission.grantUserAppPermission(
|
||||
actor!,
|
||||
uid,
|
||||
`fs:${anchorUid}:list`,
|
||||
);
|
||||
await env.server.services.permission.grantUserAppPermission(
|
||||
actor!,
|
||||
uid,
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
const token = await env.server.services.auth.getUserAppToken(actor!, uid);
|
||||
await env.server.stores.eventHandler.publish({
|
||||
appUid: uid,
|
||||
name: HANDLER,
|
||||
source: SOURCE,
|
||||
});
|
||||
return { appUid: uid, appToken: token, actor: actor! };
|
||||
};
|
||||
|
||||
/** The reused `events:handlers` worker session for (userId, appUid), if any. */
|
||||
const workerSessionFor = async (forAppUid: string) => {
|
||||
const rows = await env.server.stores.session.getByUserId(userId, {
|
||||
includeRevoked: true,
|
||||
});
|
||||
return rows.find(
|
||||
(row: {
|
||||
kind: string;
|
||||
app_uid: string;
|
||||
meta?: { worker_name?: string };
|
||||
}) =>
|
||||
row.kind === 'worker' &&
|
||||
row.app_uid === forAppUid &&
|
||||
row.meta?.worker_name === EVENTS_WORKER_SESSION_NAME,
|
||||
);
|
||||
};
|
||||
|
||||
/** A durable KV subscription on the app's own namespace, targeting the worker. */
|
||||
const subscribeKv = async (key: string): Promise<string> => {
|
||||
const response = await fetch(new URL('/events/subscribe', env.apiOrigin), {
|
||||
@@ -645,3 +703,120 @@ describe('with no events worker to address', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("what withdrawing an app's standing does to its worker session", () => {
|
||||
it('revokes the session once background consent is withdrawn', async () => {
|
||||
const app = await makeWorkerApp();
|
||||
await subscribe(app.appToken);
|
||||
await touch('consent-revoked.txt');
|
||||
await invoked(1);
|
||||
const token = calls[0].body.token!;
|
||||
expect((await workerSessionFor(app.appUid))?.revoked_at).toBeNull();
|
||||
|
||||
await env.server.services.permission.revokeUserAppPermission(
|
||||
app.actor,
|
||||
app.appUid,
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
|
||||
await vi.waitFor(async () =>
|
||||
expect(
|
||||
(await workerSessionFor(app.appUid))?.revoked_at,
|
||||
).not.toBeNull(),
|
||||
);
|
||||
await expect(
|
||||
env.server.services.auth.authenticate(token),
|
||||
).resolves.toMatchObject({
|
||||
reauth: { reason: 'session_revoked' },
|
||||
});
|
||||
});
|
||||
|
||||
it('revokes the session when the app is uninstalled wholesale', async () => {
|
||||
const app = await makeWorkerApp();
|
||||
await subscribe(app.appToken);
|
||||
await touch('uninstalled.txt');
|
||||
await invoked(1);
|
||||
const token = calls[0].body.token!;
|
||||
expect((await workerSessionFor(app.appUid))?.revoked_at).toBeNull();
|
||||
|
||||
await env.server.services.permission.revokeUserAppAll(
|
||||
app.actor,
|
||||
app.appUid,
|
||||
);
|
||||
|
||||
await vi.waitFor(async () =>
|
||||
expect(
|
||||
(await workerSessionFor(app.appUid))?.revoked_at,
|
||||
).not.toBeNull(),
|
||||
);
|
||||
await expect(
|
||||
env.server.services.auth.authenticate(token),
|
||||
).resolves.toMatchObject({
|
||||
reauth: { reason: 'session_revoked' },
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the session alone when an unrelated grant is revoked', async () => {
|
||||
const app = await makeWorkerApp();
|
||||
await subscribe(app.appToken);
|
||||
await touch('unrelated-grant.txt');
|
||||
await invoked(1);
|
||||
const token = calls[0].body.token!;
|
||||
|
||||
await env.server.services.permission.revokeUserAppPermission(
|
||||
app.actor,
|
||||
app.appUid,
|
||||
`fs:${anchorUid}:list`,
|
||||
);
|
||||
// Best-effort and async — nothing to wait *for* on the "stays alive"
|
||||
// side, so give the listener a beat before asserting the negative.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect((await workerSessionFor(app.appUid))?.revoked_at).toBeNull();
|
||||
await expect(
|
||||
env.server.services.auth.authenticate(token),
|
||||
).resolves.toMatchObject({ actor: expect.anything() });
|
||||
});
|
||||
|
||||
it('mints a fresh session on the next delivery after a re-grant', async () => {
|
||||
const app = await makeWorkerApp();
|
||||
await subscribe(app.appToken);
|
||||
await touch('regrant-before.txt');
|
||||
await invoked(1);
|
||||
const staleToken = calls[0].body.token!;
|
||||
const staleRow = await workerSessionFor(app.appUid);
|
||||
|
||||
await env.server.services.permission.revokeUserAppPermission(
|
||||
app.actor,
|
||||
app.appUid,
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
await vi.waitFor(async () =>
|
||||
expect(
|
||||
(await workerSessionFor(app.appUid))?.revoked_at,
|
||||
).not.toBeNull(),
|
||||
);
|
||||
|
||||
await env.server.services.permission.grantUserAppPermission(
|
||||
app.actor,
|
||||
app.appUid,
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
|
||||
// The withdrawn consent settled the durable row along with the
|
||||
// session, so a fresh subscribe is needed to get another delivery.
|
||||
calls.length = 0;
|
||||
await subscribe(app.appToken);
|
||||
await touch('regrant-after.txt');
|
||||
await invoked(1);
|
||||
|
||||
const freshToken = calls[0].body.token!;
|
||||
expect(freshToken).not.toBe(staleToken);
|
||||
const freshRow = await workerSessionFor(app.appUid);
|
||||
expect(freshRow?.uuid).not.toBe(staleRow?.uuid);
|
||||
expect(freshRow?.revoked_at).toBeNull();
|
||||
await expect(
|
||||
env.server.services.auth.authenticate(freshToken),
|
||||
).resolves.toMatchObject({ actor: expect.anything() });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -510,28 +510,15 @@ export class SessionStore extends PuterStore {
|
||||
*/
|
||||
async getOrCreateWorker(userId, opts = {}) {
|
||||
if (!userId || !opts.workerName) return null;
|
||||
|
||||
const existing = await this.getWorker(userId, opts);
|
||||
if (existing) return existing;
|
||||
|
||||
const appUid = opts.appUid ?? null;
|
||||
const workerName = String(opts.workerName);
|
||||
|
||||
const cacheKey = this.#cacheKeyWorker(userId, appUid, workerName);
|
||||
const now = nowSeconds();
|
||||
|
||||
const cached = await this.#readCacheKey(cacheKey);
|
||||
if (cached && cached.revoked_at == null && !isExpired(cached, now)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existing = await this.#selectWorkerRow(
|
||||
userId,
|
||||
appUid,
|
||||
workerName,
|
||||
);
|
||||
if (existing) {
|
||||
await this.#writeCacheKey(cacheKey, existing);
|
||||
this.#writeCache(existing).catch(() => {});
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = await this.#insertSession(
|
||||
userId,
|
||||
{
|
||||
@@ -561,6 +548,36 @@ export class SessionStore extends PuterStore {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only counterpart to `getOrCreateWorker` — looks up the (user, app,
|
||||
* worker_name) session row without creating one. `null` when no such
|
||||
* session exists.
|
||||
*/
|
||||
async getWorker(userId, opts = {}) {
|
||||
if (!userId || !opts.workerName) return null;
|
||||
const appUid = opts.appUid ?? null;
|
||||
const workerName = String(opts.workerName);
|
||||
|
||||
const cacheKey = this.#cacheKeyWorker(userId, appUid, workerName);
|
||||
const now = nowSeconds();
|
||||
|
||||
const cached = await this.#readCacheKey(cacheKey);
|
||||
if (cached && cached.revoked_at == null && !isExpired(cached, now)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existing = await this.#selectWorkerRow(
|
||||
userId,
|
||||
appUid,
|
||||
workerName,
|
||||
);
|
||||
if (existing) {
|
||||
await this.#writeCacheKey(cacheKey, existing);
|
||||
this.#writeCache(existing).catch(() => {});
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump `last_activity` and slide `expires_at` per the row's kind in a
|
||||
* single UPDATE. Sliding kinds (web/app/asset) get their `expires_at`
|
||||
|
||||
+37
-17
@@ -17,6 +17,28 @@ const sub = await puter.events.onLocal('fs:~/Documents', ({ event }) => {
|
||||
await sub.off();
|
||||
```
|
||||
|
||||
## Terms
|
||||
|
||||
Terms used across the Events API and its sub-pages.
|
||||
|
||||
#### Subject
|
||||
What you are watching — a file, a directory, a key-value key, or a slice of the notification mailbox. Written as a short string, e.g. `fs:~/Documents` or `kv:cart`. See [Subjects](#subjects) below.
|
||||
|
||||
#### Anchor
|
||||
`{ uid, path }` of the node a subscription is actually keyed to: the subject itself, or its nearest existing ancestor when the subject names something that does not exist yet. See [Watching something that does not exist yet](#watching-something-that-does-not-exist-yet).
|
||||
|
||||
#### Gap marker
|
||||
An event with `op: 'gap'` sent in place of one or more events a limit dropped. It means "something happened, re-read what you are watching" — not "nothing changed". See [Gaps](#gaps).
|
||||
|
||||
#### Delivery class
|
||||
Whether a persistent subscription's events go to every listener (`broadcast`, the default) or to exactly one consumer that must acknowledge each one (`single`). Set with the `delivery` option on [`onPersistent()`](/Events/onPersistent/).
|
||||
|
||||
#### Events worker
|
||||
The background runtime that invokes an app's published handlers when no client is connected to receive the delivery directly. One per app; it stands up on that app's first published handler. See [`puter.events.workers`](/Events/workers/).
|
||||
|
||||
#### Share handle
|
||||
An opaque token that lets one account subscribe to a slice of another account's key-value namespace without learning whose data it is or where in the namespace it sits. See [Sharing a region with another user](#sharing-a-region-with-another-user).
|
||||
|
||||
## Subjects
|
||||
|
||||
A subject names what you are watching, and optionally the one operation you care about:
|
||||
@@ -56,9 +78,9 @@ await puter.events.onLocal('kv:cart', ({ event }) => refresh(event.key)); // e
|
||||
await puter.events.onLocal('kv:cart*', handler); // every key starting with `cart`
|
||||
```
|
||||
|
||||
> **Exact by default; add `*` to widen.** `kv:cart` matches the key `cart` and nothing else, while `kv:cart*` matches every key starting with `cart`. This is the opposite of [`puter.kv.list()`](/KV/list/), whose `pattern` is always a prefix match with or without the `*` — a subscription has to be able to tell one key from a whole subtree, and a list does not.
|
||||
> **Exact by default; add `*` to widen.** `kv:cart` matches the key `cart` and nothing else, while `kv:cart*` matches every key starting with `cart`. This is the opposite of [`puter.kv.list()`](/KV/list/), whose `pattern` is always a prefix match with or without the `*`.
|
||||
|
||||
Only a trailing `*` is allowed. A `*` in the middle, or a `?`, is rejected with `invalid_kv_pattern`, because the server has to be able to work out an event's subjects from the key alone.
|
||||
Only a trailing `*` is allowed. A `*` in the middle, or a `?`, is rejected with `invalid_kv_pattern`.
|
||||
|
||||
A key that contains `:` needs the fully qualified three-part form, since the second segment is always read as an app id:
|
||||
|
||||
@@ -72,13 +94,13 @@ Get your own app's id from `puter.appID` and build the subject from it when your
|
||||
await puter.events.onLocal(`kv:${puter.appID}:orders:pending`, handler);
|
||||
```
|
||||
|
||||
The `subject` and `anchor` on the subscription you get back are always fully qualified, whichever form you subscribed with.
|
||||
The `subject` and [anchor](#anchor) on the subscription you get back are always fully qualified, whichever form you subscribed with.
|
||||
|
||||
Watching **another app's** key-value data takes the same consent as reading it: that app must not have opted out of data sharing, and the user must have granted your app `app-data:<appId>:kv:read`. It is checked when you subscribe and again on every delivery, so deliveries stop the moment either goes away. Where the feature is not enabled, a cross-app subject is refused with `events_cross_app_disabled`.
|
||||
|
||||
### Sharing a region with another user
|
||||
|
||||
A `kv:` subject always means your own namespace, so watching part of *someone else's* takes a **share handle**. The owner mints one over a key prefix and gives it out; whoever holds it subscribes with the handle where an app id would go:
|
||||
A `kv:` subject always means your own namespace. Watching part of *someone else's* takes a [share handle](#share-handle): the owner mints one over a key prefix and gives it out, and whoever holds it subscribes with the handle where an app id would go:
|
||||
|
||||
```js
|
||||
// The owner, sharing one workspace with another account.
|
||||
@@ -105,21 +127,21 @@ const { handle } = await res.json();
|
||||
await puter.events.onLocal(`kv:${handle}:*`, ({ event }) => render(event.key));
|
||||
```
|
||||
|
||||
The handle is the whole of what the holder learns: not whose data it is, not where in the namespace it sits, and not anything above the prefix it was granted on. Events name it too — `subject` and `key` on every delivery are relative to the handle, so what arrives reads in the same grammar the subscription was written in. Because the handle *is* the granted root, that grammar works below it — `kv:<handle>:messages:*` narrows to part of the shared region, and one handle per channel gives one subscription covering every key written in that channel.
|
||||
The handle is the whole of what the holder learns: not whose data it is, not where in the namespace it sits, and not anything above the prefix it was granted on. Events name it too: `subject` and `key` on every delivery are relative to the handle, in the same grammar the subscription was written in. `kv:<handle>:messages:*` narrows to part of the shared region, and one handle per channel gives one subscription covering every key written in that channel.
|
||||
|
||||
**Key layout is the access boundary.** A handle pins the prefix it was granted on, and nothing rewrites it afterwards: rename `workspace:<uuid>:` to `project:<uuid>:` and every handle already given out points at keys nothing writes any more. Grant on a **stable synthetic segment** — `workspace:<uuid>:`, `thread:<uuid>:` — rather than a semantic one like `acme-corp:` or `q3-planning:`, which is exactly the kind of name that gets rewritten. Files avoid this by anchoring on a uid that survives a move; keys have no such indirection.
|
||||
**Key layout is the access boundary.** A handle pins the prefix it was granted on, and nothing rewrites it afterwards: rename `workspace:<uuid>:` to `project:<uuid>:` and every handle already given out points at keys nothing writes any more. Grant on a **stable synthetic segment** — `workspace:<uuid>:`, `thread:<uuid>:` — rather than a semantic one like `acme-corp:` or `q3-planning:`, which is more likely to get renamed later.
|
||||
|
||||
`GET /events/kv-handles` lists what the account has minted, revoked ones included, and `DELETE /events/kv-handles/<handle>` takes one back — the grant goes with it, and every subscription standing on it is suspended with `permission_revoked` and its backlog dropped. Revoking is idempotent: a handle already taken back answers with the moment it stopped rather than an error. An account may hold out 200 live handles at a time; retired ones stay listed and do not count against it. Where the feature is not enabled, minting and handle subjects are refused with `events_kv_handles_disabled`.
|
||||
|
||||
A prefix names a region, so it is taken as written: `*` and `?` are refused (`invalid_kv_share_prefix`), and so is an empty key segment — `workspace::abc:` is not read as `workspace:abc:`. Only the trailing delimiter is optional.
|
||||
|
||||
An app can mint on its user's behalf, but only inside its own namespace and only where the user has said it may: the consent is `manage:kv-share:<userUuid>:<appId>:<prefix>` — the prefix contributing its segments, so `workspace:abc:` ends the string as `…:workspace:abc` — requested with [`puter.perms.request()`](/Perms/request/). It has to name a region — a request over the whole namespace is refused with `invalid_kv_share_prefix`, since that describes no bounded access — and minting outside the region it was given, or outside the app's own namespace, is refused with `events_kv_handle_not_delegated` and `events_kv_handle_outside_namespace`. An app that mints a handle still cannot list or revoke it — `GET`/`DELETE /events/kv-handles` only ever answer an account session, and an app calling either is refused with `events_kv_handle_owner_only`.
|
||||
An app can mint on its user's behalf, but only inside its own namespace and only where the user has granted it. The consent is `manage:kv-share:<userUuid>:<appId>:<prefix>` (the prefix contributing its segments, so `workspace:abc:` ends the string as `…:workspace:abc`), requested with [`puter.perms.request()`](/Perms/request/). The consent has to name a region: a request over the whole namespace is refused with `invalid_kv_share_prefix`. Minting outside the region it was given, or outside the app's own namespace, is refused with `events_kv_handle_not_delegated` and `events_kv_handle_outside_namespace` respectively. An app that mints a handle still cannot list or revoke it — `GET`/`DELETE /events/kv-handles` only ever answer an account session, and an app calling either is refused with `events_kv_handle_owner_only`.
|
||||
|
||||
A key under a handle is relative to the region it was granted on, so anything that reads as an attempt to leave it — a bare handle naming no key, or a key trying to walk out with `..` — is refused with `invalid_kv_handle_key` rather than composed into a path outside the grant.
|
||||
|
||||
### Watching something that does not exist yet
|
||||
|
||||
A subject is allowed to name a path that is not there. The subscription anchors on the nearest directory that *does* exist and the rest of the subject becomes a pattern, so the event you get is the one where it appears:
|
||||
A subject is allowed to name a path that is not there. The subscription's [anchor](#anchor) becomes the nearest directory that *does* exist, and the rest of the subject becomes a pattern matched under it — so the event you get is the one where the path appears:
|
||||
|
||||
```js
|
||||
// Nothing at this path yet — the handler runs when it is created.
|
||||
@@ -191,7 +213,7 @@ Nothing is registered and no position is kept for you: you hold the cursor. Only
|
||||
|
||||
## 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.
|
||||
`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. A Puter worker invocation is short-lived, so `onLocal()` there is only useful for the lifetime of that one invocation — a worker that wants to react to changes over time should use [`onPersistent()`](/Events/onPersistent/) with a `worker` target and a published handler instead.
|
||||
|
||||
When the connection drops and comes back — a reconnect, a sign-in, an API origin change — the SDK subscribes again for you. The handler and the subscription object stay the same; only `subId` changes, which is why nothing should be stored against it. If re-subscribing fails (the access is gone, the account signed out), or the server closes the connection outright (a revoked session, too many connections), the subscription ends and your `onError` callback is told:
|
||||
|
||||
@@ -219,17 +241,15 @@ await puter.events.onPersistent({
|
||||
|
||||
### 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.
|
||||
A handler is deployed, not called: it is serialized and run later, somewhere else, so it cannot close over any variable from where it was defined. Values reach it through **`context`** instead, evaluated once at subscribe time and capped at 4 KB. See [`puter.events.handlers`](/Events/handlers/) for the full rules and error codes, and [`onPersistent()`](/Events/onPersistent/) for how `context` is passed in.
|
||||
|
||||
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. Publishing your first handler for an app stands up an **events worker** for it; see [`puter.events.workers`](/Events/workers/) to list and destroy them.
|
||||
Publishing your first handler for an app stands up an [events worker](#events-worker) for it; see [`puter.events.workers`](/Events/workers/) to list and destroy them.
|
||||
|
||||
### Running when nobody is there takes consent
|
||||
|
||||
A persistent subscription delivers to a connected client when there is one and runs the app's handler in the background when there is not. The background half is a separate thing to agree to — your code running on the user's account with nobody watching — so it takes the per-app permission **`events:background`**, requested with [`puter.perms.request()`](/Perms/request/) and revocable wherever the user manages the app's access. Without it, subscribing with `worker` among its `targets` (the default for an app) fails with `events_background_consent_required`; taking it back suspends every worker-target subscription that app holds for that user. A subscription that only wants deliveries while your app is open asks for `targets: ['socket']` and needs no consent.
|
||||
A persistent subscription delivers to a connected client when there is one, and runs the app's handler in the background when there is not. The background half is a separate thing to agree to — your code running on the user's account with nobody watching — so it takes the per-app permission **`events:background`**, requested with [`puter.perms.request()`](/Perms/request/) and revocable wherever the user manages the app's access. Without it, subscribing with `worker` among its `targets` (the default for an app) fails with `events_background_consent_required`; taking it back suspends every worker-target subscription that app holds for that user. A subscription that only wants deliveries while your app is open asks for `targets: ['socket']` and needs no consent.
|
||||
|
||||
A third target, `'push'`, is reserved for a future device-notification transport. It is accepted today (except on a `single` subscription) but nothing delivers through it yet.
|
||||
|
||||
Pass `handler` as a **function** and it runs here too, whenever this client is the one the delivery goes to — the same body that runs in the worker, with the same `{ event, ctx, user, fetch, ack }`. See [`onPersistent()`](/Events/onPersistent/) for the acknowledgement rules; the short version is that a `single` delivery is settled by returning from the handler, and a handler that throws sees the event again.
|
||||
|
||||
@@ -237,7 +257,7 @@ A persistent subscription can also stop without you unsubscribing: its handler w
|
||||
|
||||
### Where your client is connected does not matter
|
||||
|
||||
Puter runs in several places, and a client connects to whichever one is nearest. Nothing about that is yours to think about: an event finds the connection wherever it is, `ack()` settles the delivery it belongs to whichever connection you called it on, and the shape of everything you receive is identical either way.
|
||||
Puter runs in several places, and a client connects to whichever one is nearest. An event finds the connection wherever it is, `ack()` settles the delivery on whichever connection you called it on, and the shape of everything you receive is identical either way.
|
||||
|
||||
The one consequence worth knowing is the one already stated: a `single` delivery is **at-least-once**. Undelivered events are held where the change happened, so a deployment going down loses only what it was still holding — the subscription itself, and everything already delivered, is unaffected. Handlers are asked to be idempotent for this reason, and `event.id` is the key to deduplicate on.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reads events a subject already recorded, a page at a time. This is how a client catches up after being closed, offline, or asleep — a subscription only delivers while something is listening, and `fetch()` is what fills the gap.
|
||||
Reads events a subject already recorded, a page at a time. A subscription only delivers while something is listening; `fetch()` is how a client catches up on what happened while it was closed, offline, or asleep.
|
||||
|
||||
It is a plain query. Nothing is registered, no position is stored for you, and calling it twice returns the same answer: you keep the `cursor` and pass it back as `after`.
|
||||
|
||||
@@ -48,9 +48,9 @@ Each item is a notification event:
|
||||
| `ts` | Number | When it was created, in milliseconds since the epoch. |
|
||||
| `seq` | Number | Position within the page. |
|
||||
|
||||
An app sees only what its audience allows: `account` notifications — email changed, credits exhausted, an account action — are never returned to an app, whatever subject it names, and `developer` notifications only where the recipient owns the app. Nothing is refused for asking; a slice you may not see comes back empty, so the call cannot be used to find out what exists.
|
||||
An app sees only what its audience allows: `account` notifications (email changed, credits exhausted, an account action) are never returned to an app, whatever subject it names; `developer` notifications only where the recipient owns the app. Nothing is refused for asking — a slice you may not see comes back empty, so the call cannot be used to find out what exists.
|
||||
|
||||
How long a notification is kept is deployment-configured, not a fixed number — a notification may be removed once its deployment's retention window has passed. A fetch reads whatever is still there, so a client away longer than that starts from what is left rather than from where it stopped.
|
||||
How long a notification is kept depends on the deployment's retention window, not a fixed number. A fetch reads whatever is still there, so a client away longer than the retention window starts from what is left, not from where it stopped.
|
||||
|
||||
The promise rejects with `{ message, code }` — `fetch_unsupported_subject` for a family with no store, `invalid_subject` or `invalid_subject_audience` for one that does not parse, `too_many_requests` over the fetch budget, `events_disabled` where events are off, `events_failed` for anything the server answered that the SDK could not make sense of.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ await puter.events.handlers.remove('indexDocument', { appUid });
|
||||
|
||||
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`. `puter` is **not** one of them: a handler running in the events worker has no ambient SDK, and reaches the account through its `user` binding instead — the same authority your app has for that user in a tab, not a narrower one. The SDK checks this before the request and rejects with `events_handler_free_variable`, naming the identifier:
|
||||
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`. `puter` is **not** one of them — a handler running in the [events worker](/Events/#events-worker) has no ambient SDK, and reaches the account through its `user` binding instead, with the same authority your app has for that user in a tab. The SDK checks this before the request and rejects with `events_handler_free_variable`, naming the identifier:
|
||||
|
||||
```js
|
||||
const endpoint = 'https://example.com/ingest';
|
||||
@@ -100,11 +100,11 @@ Resolves to `{ name, removed, suspended }`.
|
||||
|
||||
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.
|
||||
|
||||
**An app's first published handler stands up an events worker for it.** See [`puter.events.workers`](/Events/workers/) to list and destroy them — the last handler removed here takes it down the same way.
|
||||
**An app's first published handler stands up an [events worker](/Events/#events-worker) for it.** See [`puter.events.workers`](/Events/workers/) to list and destroy them — the last handler removed here takes it down the same way.
|
||||
|
||||
### Refusing a delivery outright
|
||||
|
||||
A handler running in the events worker normally has two outcomes: return (or resolve) and the delivery is taken, or throw and it is retried later. Sometimes neither is right — the delivery is malformed in a way retrying never fixes. Throw an error with `terminal: true`, or a `code` of `'events_terminal'`, and it is refused instead of retried: the invocation answers a `4xx` rather than the usual `5xx`, and the delivery is dropped with a `gap` marker carrying `reason: 'handler_rejected'` rather than sent again to the same handler.
|
||||
A handler running in the events worker normally has two outcomes: return (or resolve) and the delivery is taken, or throw and it is retried later. Sometimes neither is right — the delivery is malformed in a way retrying never fixes. Throw an error with `terminal: true`, or a `code` of `'events_terminal'`, and it is refused instead of retried. The invocation answers a `4xx` rather than the usual `5xx`, and the delivery is dropped with a [gap marker](/Events/#gap-marker) carrying `reason: 'handler_rejected'` instead of being sent again to the same handler.
|
||||
|
||||
```js
|
||||
await puter.events.handlers.publish('ingestUpload', async ({ event }) => {
|
||||
@@ -121,7 +121,7 @@ See [`onPersistent()`](/Events/onPersistent/) for the full `2xx`/`4xx`/`5xx` map
|
||||
|
||||
### 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.
|
||||
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](/Events/#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
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ platforms: [websites, apps, nodejs, workers]
|
||||
|
||||
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.
|
||||
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** — so the account is where a stray subscription gets revoked from.
|
||||
|
||||
## Syntax
|
||||
```js
|
||||
@@ -35,6 +35,7 @@ Each subscription is the object [`onPersistent()`](/Events/onPersistent/) return
|
||||
|
||||
- `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`.
|
||||
- `targets` (Array) may list `'push'` — it is accepted when subscribing, but nothing delivers through it yet.
|
||||
|
||||
The promise rejects with `{ message, code }` — `too_many_requests` over the listing budget, `events_disabled` where events are off, `events_failed` for anything the server answered that the SDK could not make sense of.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: subscription.off()
|
||||
description: End a subscription created with puter.events.onLocal().
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
platforms: [websites, apps, nodejs]
|
||||
---
|
||||
|
||||
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
---
|
||||
title: puter.events.onLocal()
|
||||
description: Subscribe to changes on a file, directory, or key-value key for as long as this client is connected.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
platforms: [websites, apps, nodejs]
|
||||
---
|
||||
|
||||
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Subscribes to a subject and calls `handler` every time something matching it changes. The subscription belongs to this client's connection: nothing is stored, nothing runs while the page is closed, and it ends when the connection does. See [Events](/Events/) for the subject grammar and the event shape.
|
||||
|
||||
Not for a Puter worker: a worker invocation is short-lived, so a subscription here only lasts as long as that one invocation. To react to changes from a worker, use [`onPersistent()`](/Events/onPersistent/) with a `worker` target and a published handler.
|
||||
|
||||
## Syntax
|
||||
```js
|
||||
puter.events.onLocal(subject, handler)
|
||||
@@ -36,9 +38,8 @@ Called with a single `{ event }` object per delivery. `event.op === 'gap'` means
|
||||
A `Promise` that resolves, once the server has confirmed the subscription, to a subscription object:
|
||||
|
||||
- `subId` (String | null): The server's id for the subscription. It changes whenever the connection is rebuilt, so don't store anything against it.
|
||||
- `subject` (String): The subject you subscribed with.
|
||||
- `subject` is returned fully qualified: a `kv:` subject you wrote in the two-segment form comes back as `kv:<appId>:<key>`.
|
||||
- `anchor` (Object): `{ uid, path }` of the node the subscription is keyed to — the nearest existing ancestor when the subject named something that does not exist yet. For a `kv:` subject, `uid` is the app whose store is being watched and `path` is the key prefix it is anchored at; for one made through a share handle, `uid` is the handle and `path` is empty. The path is the one the anchor had when you subscribed — a later rename or move does not update it.
|
||||
- `subject` (String): The subject you subscribed with, returned fully qualified — a `kv:` subject you wrote in the two-segment form comes back as `kv:<appId>:<key>`.
|
||||
- `anchor` (Object): The subscription's [anchor](/Events/#anchor), as `{ uid, path }`. For a `kv:` subject, `uid` is the app whose store is being watched and `path` is the key prefix it is anchored at; for one made through a share handle, `uid` is the handle and `path` is empty. The path is the one the anchor had when you subscribed — a later rename or move does not update it.
|
||||
- `match` (String | null): The pattern events under the anchor are matched against, if the subject had one.
|
||||
- `op` (String | null): The single operation this subscription is limited to, or `null` for all of them.
|
||||
- `off` (Function): Ends the subscription — see [`subscription.off()`](/Events/off/).
|
||||
|
||||
@@ -20,8 +20,8 @@ puter.events.onPersistent(options)
|
||||
#### `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.
|
||||
- `delivery` (String): The [delivery class](/Events/#delivery-class). `'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 subscription with no app may not target `'worker'` — there is exactly one [events worker](/Events/#events-worker) per app, and no app means no worker to invoke. `'push'` is reserved for a future device-notification transport: it is accepted (except on a `single` subscription, which may not target it) but nothing delivers through it yet.
|
||||
- `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.
|
||||
@@ -29,15 +29,15 @@ puter.events.onPersistent(options)
|
||||
|
||||
## Background delivery takes the user's consent
|
||||
|
||||
A persistent subscription can run your handler when nobody is there — a different thing from delivering to a page the user has open — so it takes its own per-app permission, **`events:background`**. Subscribing with `worker` among its `targets` without it fails with `events_background_consent_required`, and `['socket', 'worker']` is the default for a subscription an app creates. Ask for it the way you ask for anything else:
|
||||
Running your handler when nobody is there is a different thing from delivering to a page the user has open, so it takes its own per-app permission, **`events:background`**. `['socket', 'worker']` is the default `targets` for a subscription an app creates; subscribing with `worker` among them without the permission fails with `events_background_consent_required`. Request it like any other permission:
|
||||
|
||||
```js
|
||||
await puter.perms.request(['events:background']);
|
||||
```
|
||||
|
||||
The user can take it back wherever they manage an app's access; every worker-target subscription that app holds for them is then suspended with `permission_revoked`, and re-granting does not bring one back — subscribe again. A subscription that only wants deliveries while your app is open needs no consent at all: pass `targets: ['socket']`.
|
||||
The user can revoke it wherever they manage an app's access. Doing so suspends every worker-target subscription that app holds for them with `permission_revoked`; re-granting the permission does not resume them, so subscribe again. A subscription that only wants deliveries while your app is open needs no consent at all: pass `targets: ['socket']`.
|
||||
|
||||
A background delivery runs as a session, the same as any other your app is granted — it shows up in the user's own sessions list as a worker session, and revoking it there stops background handlers for your app the same way withdrawing `events:background` does.
|
||||
A background delivery runs as a session, the same as any other your app is granted — it shows up in the user's own sessions list as a worker session, and revoking it there stops background handlers for your app the same way withdrawing `events:background` does. Withdrawing `events:background` or uninstalling the app revokes that session in turn, so a copied-out token stops working too.
|
||||
|
||||
## Where the handler runs, and what it is handed
|
||||
|
||||
@@ -53,7 +53,7 @@ The handler runs **in this client while it is connected**, and in the app's even
|
||||
|
||||
Passing `handler` as a **function** is what registers it to run here; a source string or `{ file }` is sent as a hash only, and nothing runs client-side. Either way the hash must match what is published under `handlerName`.
|
||||
|
||||
Those five bindings are the whole environment. In the events worker there is no ambient `puter` and no identity of your own to act as — a delivery says whose it is, and `user` is it — so a handler that names `puter` or `me` is refused when you publish it rather than failing on its first delivery. That identity carries your app's own reach for that user — its KV, its AppData, whatever else they have granted it — the same as any session your app runs while they have a tab open.
|
||||
Those five bindings are the whole environment. The events worker has no ambient `puter` and no identity of your own to act as — a handler that names `puter` or `me` is refused when you publish it, rather than failing on its first delivery. `user` is that identity instead: it carries your app's own reach for that account — its KV, its AppData, whatever else the user has granted it — the same as any session your app runs while they have a tab open.
|
||||
|
||||
### Acknowledging a `single` delivery
|
||||
|
||||
@@ -65,11 +65,11 @@ A `single` delivery is owed to exactly one consumer, so it stays owed until it i
|
||||
|
||||
In the events worker the same three outcomes are the response status: `2xx` takes the delivery, `4xx` refuses it (it is dropped with a `gap` marker carrying `reason: 'handler_rejected'`), and `5xx`, `429` or no answer within 30 seconds means "not now" — the delivery is retried after 2 seconds, doubling to at most 5 minutes. **Five failures in a row, refusals included, suspend the subscription** with `failures`; the developer is notified and republishing the handler puts it back in service.
|
||||
|
||||
A handler that throws normally lands on the retriable side (`5xx`) — the failure might be transient. To refuse a delivery outright instead — a malformed event, say, where retrying changes nothing — throw an error with `terminal: true`, or a `code` of `'events_terminal'`; the worker maps that to a `4xx`, the same `handler_rejected` gap a plain refusal gets. This only matters in the events worker: thrown here, in the client, it just reaches whatever caught the promise.
|
||||
A handler that throws normally lands on the retriable side (`5xx`), since the failure might be transient. To refuse a delivery outright instead — a malformed event, say, where retrying changes nothing — throw an error with `terminal: true`, or a `code` of `'events_terminal'`. The worker maps that to a `4xx`, the same `handler_rejected` gap a plain refusal gets. This only matters in the events worker: thrown in the client, it just reaches whatever caught the promise.
|
||||
|
||||
## `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.
|
||||
A handler cannot close over anything (see [`puter.events.handlers`](/Events/handlers/)), so `context` is how values reach it. 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({
|
||||
@@ -109,7 +109,7 @@ The promise rejects with `{ message, code }`:
|
||||
| `events_background_consent_required` | The subscription targets `worker` and the user has not granted this app `events:background`. |
|
||||
| `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_targets` | A target outside `socket`/`worker`/`push`, `push` on a `single` subscription (which may not target it), 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. |
|
||||
|
||||
@@ -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>
|
||||
|
||||
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.
|
||||
Ends a subscription created with [`puter.events.onPersistent()`](/Events/onPersistent/). It stops matching immediately, and any backlog it was still owed is dropped with it.
|
||||
|
||||
For a session subscription made with [`puter.events.onLocal()`](/Events/onLocal/), use [`subscription.off()`](/Events/off/) instead.
|
||||
|
||||
|
||||
@@ -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** is what runs an app's published [handlers](/Events/handlers/) — one stands up the first time an app publishes a handler, and comes down when the last one is removed. It is a per-app artifact, not a per-handler one: an app with five published handlers still has exactly one events worker behind them.
|
||||
An [events worker](/Events/#events-worker) runs an app's published [handlers](/Events/handlers/). It is a per-app artifact, not a per-handler one: an app with five published handlers still has exactly one events worker behind them.
|
||||
|
||||
A hosted Puter deployment may bill an events worker as a standing monthly cost, one charge per app that has one — publishing handlers you no longer use keeps that meter running even if nothing ever delivers to them. This surface is where an app owner sees what it is running and stops paying for one it does not need.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user