puter.events.onPersistent()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(). Compare puter.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.
See Subjects and events for the subject syntax and the event shape.
On a website with nobody signed in, it asks the user to sign in first, as other Puter.js calls do. An app running on Puter is always signed in.
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 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).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.Running your handler while the user isn't there needs its own per-app permission, events:background. Subscribing with worker among the targets (the default for an app) without it fails with events_background_consent_required. Request it like any other permission:
await puter.perms.request(['events:background']);
The user can revoke it wherever they manage the app's access. That suspends every worker-target subscription the app holds for them with permission_revoked, and granting it again doesn't resume them: subscribe again. A subscription that only wants deliveries while your app is open needs no consent: pass targets: ['socket'].
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.
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:
| Delivery | Runs |
|---|---|
broadcast, default targets (['socket', 'worker']) |
The app's events worker. Connected clients run it instead only when the worker can't take it: no worker runtime on this deployment, or the handler-run limit 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). |
Whichever place runs it, it's the same code, called with:
| Binding | What it is |
|---|---|
event |
The event, or a gap marker. |
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 where it exists, otherwise the environment's fetch. |
ack |
On a single subscription only; see below. |
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.
Those five bindings are the handler's whole environment. The events worker has no ambient puter: a handler that names puter or me is refused at publish time. Use user instead.
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.
user in the events worker is valid for 15 minutes, so don't keep it past the run.
single delivery
A single delivery stays owed until it's acknowledged:
ack() takes the delivery.ack() also takes it.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 |
|---|---|
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. |
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.
context is captured once, up to 4 KB
A handler can't use outside variables (see puter.events.handlers), so context is how values reach it. It's evaluated once, at this call. Below, ctx.endpoint stays whatever process.env.INGEST_URL was when you subscribed, until you subscribe again:
await puter.events.onPersistent({
subject: 'fs:~/inbox',
handlerName: 'ingestUpload',
context: { endpoint: process.env.INGEST_URL, apiKey: process.env.INGEST_KEY },
});
Over 4 KB serialized, the call fails with events_context_too_large before any request is made. Context is stored in plaintext and only read when delivering. puter.events.list() returns its key names and a hash, never the values. For more than 4 KB, put the data in a file and pass the path in context.
A persistent subscription can stop without being unsubscribed. It's then suspended, not deleted, and list() shows suspendedAt and suspendedReason:
suspendedReason |
Cause | Resumes when |
|---|---|---|
handler_not_found |
Its handler was removed. | A handler is published under that name again. |
failures |
Its handler failed 5 times in a row. | The handler is published again. |
no_credit |
Its holder ran out of credit. | The balance is topped up (checked every few minutes). |
permission_revoked |
The permission or share it relied on was withdrawn. | Never. Subscribe again. |
A suspended subscription stops delivering and isn't billed. Its backlog is cut to 100 deliveries and kept for 24 hours (handler_not_found, failures) or 1 hour (no_credit), then replaced by one gap marker. A permission_revoked backlog is dropped immediately. Suspended subscriptions are deleted after 30 days.
Clients connect to the nearest region. Events reach a client wherever it's connected, and ack() works on any connection.
single deliveries are at-least-once: one may arrive twice, with the same event.id. Make handlers safe to run twice.A Promise that resolves to the subscription:
subId (String): Its id, which puter.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() returns it.anchor, match, op: as onLocal() returns them.delivery (String), targets (Array), 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.suspendedAt (Number | null), suspendedReason (String | null): set when the subscription is suspended.off() (Function): Ends the subscription and stops running its handler here. Same as puter.events.unsubscribe(subId).The promise rejects with { message, code }:
code |
Meaning |
|---|---|
invalid_subject |
The subject is empty, not a string, or can't be parsed, or it's a notif: subject and this server has notification events turned off. |
auth_canceled |
Nobody was signed in, and the user closed the sign-in without finishing it. |
events_handler_name_required |
handler was given without handlerName. |
events_handler_free_variable |
The handler uses an outside variable. The message names it. |
events_handler_invalid |
handler is not a function, a source string, or { file }. |
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_context_too_large |
The serialized context is over 4 KB. |
events_context_invalid |
context is not JSON-serializable. |
invalid_targets |
An unknown target, push with single, or worker with no app. |
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. |
events_subscription_limit |
The account or app is at its persistent subscription limit. |
events_value_too_large |
A field is longer than can be stored (for example an app id over 40 characters). |
events_durable_requires_account |
Temporary (anonymous) accounts only get session subscriptions. Scoped access tokens, such as the one in a getReadURL() URL, get no persistent subscriptions, whether the account or an app created them. |
too_many_requests |
Over the subscribe rate limit. |
events_disabled |
Events aren't enabled on this server. |
events_failed |
The server sent a response the SDK couldn't read. |
Watch a folder with a handler that keeps running
<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>
Bind to the exact source you wrote against
<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>