puter.events.handlersA handler is a function your app publishes once, under a name, for persistent subscriptions to run. Nothing triggers a handler by name; it runs only when a subscription bound to it has a delivery.
Publishing is a developer operation: the account must own the app. An app token publishes into its own app; an account session names the app with appUid.
On a website with nobody signed in, each method asks the user to sign in first, as other Puter.js calls do. An app running on Puter is always signed in.
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 });
A handler is serialized with Function.prototype.toString() and run later, somewhere else, so nothing around the function comes with it.
Every identifier a handler uses must be a parameter, something the handler declares itself, a standard global (fetch, JSON, Math, console, URL, crypto, …), or reached through ctx. puter isn't available in the events worker; use the user binding, which has the same access your app has for that user in a tab. The SDK checks this before sending and rejects with events_handler_free_variable, naming the identifier:
const endpoint = 'https://example.com/ingest';
// Rejected: `endpoint` isn't 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 strict on purpose: anything it can't resolve is refused now instead of failing on the first delivery.
publish()
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 with a letter or digit, up to 128 characters. Unique within the app.handler (Function | String | Object) (required): A function (serialized with toString()), a source string, or { file: '~/AppData/…/handler.js' }. A file is read now, so later edits to it take effect only when you publish again.options.replace (Boolean): Overwrite whatever is published under the name.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' (the same source was already published). resumed counts suspended subscriptions this publish brought back.
Publishing the same source again does nothing. Publishing different source updates the handler, but only if you know what you're replacing.
The SDK remembers the hash it last saw for each name and sends it with the publish. If someone else published in between, the publish fails with events_handler_conflict. Pass replace: true to overwrite anyway. A client that has never published or listed the name sends no hash, so it can only create the name or republish identical source.
publishAll()
puter.events.handlers.publishAll(handlers)
puter.events.handlers.publishAll(handlers, options)
Publishes a set in one call, for build steps. handlers is an array of up to 50 { name, handler, replace? }, published in order. If one is refused, publishing stops there: earlier items stay published, and the error says where it stopped.
Resolves to an array of publish() results.
list()
puter.events.handlers.list()
puter.events.handlers.list(options)
Resolves to [{ name, hash, updatedAt, subscriptions }] for every handler the app has published, sorted by name. subscriptions counts the subscriptions bound to that name, including suspended ones.
Source is never returned.
remove()
puter.events.handlers.remove(name)
puter.events.handlers.remove(name, options)
Resolves to { name, removed, suspended }.
| Situation | What happens |
|---|---|
| No subscriptions use the name | The handler is deleted. |
| Subscriptions use it | The handler is deleted, and its subscriptions are suspended with suspendedReason: 'handler_not_found', not deleted. The app's developer is notified. |
Publishing the name again resumes them, with the same ids and context. This is how you recover from a bad deploy.
Renaming is publishing the new name and removing the old one. Subscriptions don't follow: users have to subscribe again, so their subscriptions never silently switch to different code.
An app's first published handler creates its events worker, and removing the last one takes it down.
In the events worker, a handler that returns takes the delivery, and one that throws is retried later. When retrying can't help (a malformed event, say), throw an error with terminal: true or code: 'events_terminal'. The delivery is dropped and replaced by a gap marker with reason: 'handler_rejected'.
await puter.events.handlers.publish('ingestUpload', async ({ event }) => {
if (! event.path.endsWith('.json')) {
const err = new Error(`cannot ingest ${event.path}`);
err.terminal = true;
throw err;
}
// ...
}, { appUid });
See onPersistent() for the full list of outcomes, and suspended subscriptions for what happens to a suspended subscription's backlog.
All four methods reject with { message, code }:
code |
Meaning |
|---|---|
auth_canceled |
Nobody was signed in, and the user closed the sign-in without finishing it. |
events_handler_free_variable |
The handler uses an outside variable. The message names it. |
events_handler_invalid |
handler isn't a function, a source string, or { file }. |
events_handler_name_invalid |
The name is empty, too long, or has characters that aren't allowed. |
events_handler_conflict |
Someone else published different source under this name since you last saw it. Pass replace: true to overwrite. |
events_handler_app_required |
An account session didn't pass appUid. |
events_handler_forbidden |
The caller doesn't own the app (or it doesn't exist), or is a scoped access token. |
events_handler_too_large |
The handler is over 64 KB. |
events_worker_too_large |
The app's handlers would exceed 5 MB combined. |
events_handler_source_invalid |
The handler source is empty. |
events_handler_limit |
The app already has the maximum number of handlers. |
too_many_requests |
Over the publish/remove rate limit. |
events_disabled |
Events aren't enabled on this server. |
events_failed |
The server sent a response the SDK couldn't read. |
Publish a handler, bind a subscription to it, then take it away
<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>
Deploy a whole set from a build step
<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>