feat: the per-app events worker runtime (#3697)

* feat: bake published handlers into a generated events worker

* feat: deploy and address the per-app events worker behind a flag

* test: single delivery end to end through a real local worker

* feat: events workers run their own runtime, in their own namespace

An events worker was being deployed as an ordinary worker: default dispatch
namespace, a `subdomains` row, the router preamble, and an app-scoped worker
token baked in. The public dispatcher resolves any script in that namespace
straight off the hostname, so the worker answered at `<name>.puter.work`, and
the only thing in front of it was an unguessable name plus a check that a
`puter-auth` header was present — which the router never validates. Anyone who
learned the hostname could run an app's handlers with a body of their choosing,
in an isolate holding the owner's token as `me`.

Instead:

- Handlers run on their own runtime (`src/worker/src/events-runtime.js`), which
  provides no `router` and no `me`, owns the single invoke route, and hands a
  handler only `{ event, ctx, user, fetch, ack }`. `user` is built from the
  invocation's delivery token, so a handler acts as the subscriber whose
  delivery it is and nothing wider. The preamble build emits one bundle per
  runtime; the shared half of the template is now included by both.
- The deploy target carries the runtime to prepend, the source to deploy, and
  whether to mint a worker token at all, so an events worker deploys into the
  `events` dispatch namespace from generated source with no token binding, no
  `subdomains` row, and no claim on the owner's worker quota or worker list.
- An invocation carries a key derived from the deployment secret and the script
  name, bound as a secret and checked in constant time inside the isolate,
  which reads it once and drops it before handler code runs.
- Scripts are named after the handler set they contain, so publishing writes
  rows and deploys nothing: a set is deployed the first time a delivery needs
  it, and a changed set is a new script rather than an overwrite of a running
  one. Publish responses keep the shape they had before the runtime existed.
- Invocations reach a worker only through the events dispatcher, which has no
  zone route and requires the internal secret; the backend's own deploy path is
  the rehydrate route the dispatcher calls on a namespace miss. Locally there is
  no dispatcher, so the controller hands the service an in-process transport
  that deploys on miss itself.

The SDK stops allowlisting `puter` as a handler global — a handler that reaches
for an ambient SDK is now refused at publish time, naming `user` instead, rather
than passing the scan and failing on its first delivery.

Requires `events.workerNamespace`, `events.dispatcherUrl` and
`events.internalSecret`; without them nothing is addressable and background
deliveries stay retriable, as they did with the runtime off.

* fix: a handler's delivery token gets through the read routes

An events handler acts as the subscriber through the access token its
invocation carried, but every FS read route refused scoped access tokens
outright, so `user.fs.stat(event.path)` — the design's own example — answered
403 inside the worker. The read-side routes now admit them; the ACL each
handler already runs intersects the token's grant with its issuer's, which is
the check that keeps a token to what it was minted for. The end-to-end suite
asserts the stat from inside the isolate.

* fix: shorthand-method handlers publish as functions

`{ ingest({ event }) { … } }` stringifies without the `function` keyword, so
its source is not an expression and the events worker baked it as a broken
stub — every delivery a retriable 500 until the subscription suspended, with
nothing at publish time to say why. The SDK now gives a shorthand method the
keyword before hashing and sending; getters, setters and computed names are
left for the server-side check to refuse.

* feat: an app's events worker is listable and destroyable

An app with published handlers has an events worker, and hosted deployments
bill it monthly per app, so its owner needs to see it and be able to take it
down. The core announces the lifecycle on the bus — `events.worker.create`
when an app's first handler is published, `events.worker.destroy` when its last
one goes — with the owner as the actor, so pricing can plug in from outside.
`GET /events/workers` lists the caller's workers (paginated, with the script
each set deploys as) and `POST /events/workers/destroy` removes every handler
of an app under the same owner scoping as the handler routes, suspending the
subscriptions bound to them. `puter.events.workers.list/destroy` in the SDK,
a docs page, and a 5 MB cap on an app's combined handler source
(`events_worker_too_large`) so a set that publishes can always deploy.

* fix: harden the events worker runtime for production

- A 4xx is terminal only when it carries the handled marker the runtime (and
  the dispatcher) stamp on every answer that came from a script; an unmarked
  4xx — an edge 404 for a wrong dispatcher hostname, a WAF page — stays
  retriable and is logged, once per script per minute, with the runtime's
  reason header.
- Script names are scoped to this backend's exposed API origin, so two
  backends sharing a namespace never resolve one script with the wrong
  endpoint binding or key. Shape unchanged.
- Each handler is validated in the exact context it is emitted into and the
  whole generated file is compiled once; a source that would break the script
  marks every handler broken instead of deploying a SyntaxError.
- Locally, events scripts live under their own registry key: the public local
  worker host cannot reach them and an ordinary worker cannot take their name.
- A suspended or deleted app owner stops invocations; deploys are throttled
  per app per hour; in-flight deploys are keyed by app and script; the
  upstream deploy call times out; the generated source is size-capped with a
  margin over the publish cap; boot fails when the runtime is on but its
  preamble is not built. Byte-length secret compare, appUid shape check,
  dispatcher URL prefix preserved, wider connection pool.

* feat: background workers are listed in the sessions manager

A user paying for an app's events worker needs somewhere to see it and take it
down. The sessions manager gets a section listing the apps that run event
handlers in the background, with a Destroy action that removes their published
handlers.
This commit is contained in:
Daniel Salazar
2026-09-04 17:32:57 -07:00
committed by GitHub
parent dfab3d50cd
commit f30baa2a1c
51 changed files with 5400 additions and 302 deletions
+2 -1
View File
@@ -222,7 +222,7 @@ Values reach a handler through **`context`**, which is evaluated **once, at subs
**`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.
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.
### Running when nobody is there takes consent
@@ -251,3 +251,4 @@ Subscriptions per connection, persistent subscriptions per account, published ha
- **[`puter.events.unsubscribe()`](/Events/unsubscribe/)** - End a persistent subscription
- **[`puter.events.fetch()`](/Events/fetch/)** - Read what a subject recorded while nothing was listening
- **[`puter.events.handlers`](/Events/handlers/)** - Publish, list and remove the named handlers a persistent subscription runs
- **[`puter.events.workers`](/Events/workers/)** - List and destroy the events worker a published handler set stands up
+4 -1
View File
@@ -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`. The SDK checks this before the request and rejects with `events_handler_free_variable`, naming the identifier:
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 SDK checks this before the request and rejects with `events_handler_free_variable`, naming the identifier:
```js
const endpoint = 'https://example.com/ingest';
@@ -100,6 +100,8 @@ 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.
### 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.
@@ -117,6 +119,7 @@ All four methods reject with `{ message, code }`:
| `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_worker_too_large` | The app's handlers would exceed 5 MB of source combined. |
| `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. |
+2
View File
@@ -51,6 +51,8 @@ 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.
### Acknowledging a `single` delivery
A `single` delivery is owed to exactly one consumer, so it stays owed until it is acknowledged:
+87
View File
@@ -0,0 +1,87 @@
---
title: puter.events.workers
description: List and destroy the events worker a published handler set stands up.
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.
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.
```js
const { items } = await puter.events.workers.list(); // [{ appUid, appName, handlerCount, ... }]
await puter.events.workers.destroy(items[0].appUid); // removes every handler that app published
```
Unlike `puter.events.handlers`, this is account-scoped rather than app-scoped: `list()` takes no `appUid` and always answers for every app *you* own, and an app token cannot list or destroy on its owner's behalf — only an account session, or the app itself destroying its own worker, may call these.
## `list()`
```js
puter.events.workers.list()
puter.events.workers.list(options)
```
- `options.limit` (Number): Apps per page.
- `options.cursor` (String): The `cursor` from a previous page. Omit to start from the first page.
Resolves to `{ items, cursor, deployable }`. Each item is `{ appUid, appName, appTitle, handlerCount, createdAt, updatedAt, script }` — `createdAt` is when the app's earliest handler was published (its events worker's birth), `updatedAt` is its most recently published or updated handler, and `script` names the deployed script, useful when reporting an issue. `cursor` is present only while more pages exist. `deployable` reports whether this server actually deploys events workers at all — `false` on a self-hosted install that has not turned the runtime on, in which case handlers can still be published but nothing ever runs a background delivery for them.
## `destroy()`
```js
puter.events.workers.destroy(appUid)
```
Removes **every** handler the named app has published, in one call — the same consequences as calling [`puter.events.handlers.remove()`](/Events/handlers/) on each of them: a name nothing is bound to is deleted outright, and a name with subscriptions on it is deleted with those subscriptions *suspended* (`suspendedReason: 'handler_not_found'`), never dropped. Publishing new handlers for the app afterwards resumes them, exactly as republishing a single removed handler would.
Resolves to `{ appUid, removed, suspended }` — `removed` is how many handlers were deleted, `suspended` how many subscriptions that left suspended across all of them. An app with nothing published rejects with `events_handler_not_found`.
## Errors
Both methods reject with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `events_worker_owner_only` | `list()` was called by an app rather than an account session. |
| `events_handler_not_found` | `destroy()` named an app with no published handlers. |
| `events_handler_forbidden` | The caller does not own the app named to `destroy()` — and an app that is not there answers the same way. |
| `too_many_requests` | Over the handler publish/remove or listing budget. |
| `events_disabled` | Events are not enabled on this server. |
## Example
<strong class="example-title">List an account's events workers, and destroy one that is no longer needed</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const name = `retired-${puter.randName()}`;
const app = await puter.apps.create(name, `https://example.com/${name}`);
await puter.events.handlers.publish(
'ingestUpload',
async ({ event, ctx }) => {
await fetch(ctx.endpoint, { method: 'POST', body: event.path });
},
{ appUid: app.uid },
);
const { items, deployable } = await puter.events.workers.list();
puter.print(`this server deploys events workers: ${deployable}<br>`);
for (const worker of items) {
puter.print(`${worker.appName}: ${worker.handlerCount} handler(s)<br>`);
}
const destroyed = await puter.events.workers.destroy(app.uid);
puter.print(`removed ${destroyed.removed}, suspended ${destroyed.suspended}<br>`);
})();
</script>
</body>
</html>
```
+5 -2
View File
@@ -195,6 +195,7 @@ A temporary (anonymous) account cannot create durable subscriptions at all — `
| Handlers per `publishAll` call | 50 |
| Handler publish / remove calls per minute | 60 |
| Handler listings per minute | 120 |
| Events worker listings per minute | 120 |
`fetch()` reads a page of what a subject recorded rather than a delivery, so it is budgeted with the listings: a page defaults to 50 events and is capped at 200, and a client catching up walks pages until one comes back with no cursor. Only `notif:` has a store to read — the notification mailbox, kept for 14 days — and any other subject family is refused with `fetch_unsupported_subject`.
@@ -204,7 +205,9 @@ The 51st subscription on one connection, and the durable subscription past your
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 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 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. All of an app's handlers combined may not exceed **5 MB** of source; a publish that would push the total over that is refused with `events_worker_too_large`. 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.
The first published handler brings up an **events worker** for that app; the last one removed, or `puter.events.workers.destroy()`, takes it down. An app's events worker may (re)deploy at most **30 times an hour**; past that, delivery stays retriable until the hour rolls over. `puter.events.workers.list()` shows every app you own that currently has one — see [`puter.events.workers`](/Events/workers/) for details, including how a hosted deployment may bill it.
**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:
@@ -227,7 +230,7 @@ A `kv:` subject is indexed on the first **6** `:`-segments, or **160 bytes**, of
The three per-event ceilings do not fail your call — they truncate the delivery and send a `gap` marker in its place, an event with `op: 'gap'` and no `uid` or `path`. A gap means something happened that you were not told the details of, so a client that must not miss changes should re-read the anchor when it sees one rather than treat the silence as "nothing changed".
A **background delivery** — one that runs your app's handler with nobody there — takes the user's consent, the per-app permission `events:background`, and a subscription targeting `worker` without it is refused with `events_background_consent_required`. A handler has **30 seconds** to answer each invocation. Answering `2xx` takes the delivery; `4xx` refuses it, and it is dropped with a `gap` marker carrying `reason: 'handler_rejected'` rather than sent again to the same answer; `5xx`, `429` and a timeout are all "not now", and the delivery is held **2 seconds** before the next attempt, doubling each time up to **5 minutes**. **Five failures in a row** — refusals included — suspend the subscription with `failures`, hold what it is owed under the suspended-backlog rules above, and notify the app's developer. Until an events worker is deployed for an app there is nothing to invoke, so a worker-target subscription self-limits along exactly this path.
A **background delivery** — one that runs your app's handler with nobody there — takes the user's consent, the per-app permission `events:background`, and a subscription targeting `worker` without it is refused with `events_background_consent_required`. A handler has **30 seconds** to answer each invocation. Answering `2xx` takes the delivery; `4xx` refuses it, and it is dropped with a `gap` marker carrying `reason: 'handler_rejected'` rather than sent again to the same answer; `5xx`, `429` and a timeout are all "not now", and the delivery is held **2 seconds** before the next attempt, doubling each time up to **5 minutes**. **Five failures in a row** — refusals included — suspend the subscription with `failures`, hold what it is owed under the suspended-backlog rules above, and notify the app's developer. Publishing a handler is all the deployment there is: the app's events worker is brought up the first time a delivery needs it, and again if it has been idle long enough to be evicted, so the first background delivery after a publish pays a short cold start. Nothing else can invoke it — it answers one platform route, and only the platform can reach it.
A `single` subscription is delivered to exactly one consumer, which has **30 seconds** to acknowledge each delivery before it is offered again — twice to a connected client, then to the subscription's handler. Until it is acknowledged it is held for you, so a consumer that is away is a backlog that grows: **10,000** undelivered deliveries per subscription, after which the oldest are dropped and one `gap` marker with `reason: 'backlog_overflow'` takes their place. Each region also holds at most **1,000,000** undelivered deliveries across every subscription it serves, and sheds the oldest first — with the same marker — before it reaches that. A redelivery after a missed acknowledgement is normal and expected: deliveries are at-least-once, `event.id` is stable across them, and a handler that runs twice on the same id should do nothing the second time.
+8
View File
@@ -462,6 +462,14 @@ let sidebar = [
source: '/Events/handlers.md',
path: '/Events/handlers',
},
{
title: '<code>workers</code>',
page_title: '<code>puter.events.workers</code>',
title_tag: 'puter.events.workers',
icon: '/assets/img/function.svg',
source: '/Events/workers.md',
path: '/Events/workers',
},
],
},
{