From f30baa2a1c0670f42ecb4eb0ebb37f5fb07f0a15 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Fri, 4 Sep 2026 17:32:57 -0700 Subject: [PATCH] feat: the per-app events worker runtime (#3697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `.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. --- src/backend/clients/event/types.ts | 8 + .../events/EventsWorkerInvokerClient.test.ts | 155 ++++ .../events/EventsWorkerInvokerClient.ts | 328 +++++--- .../controllers/events/EventsController.ts | 160 ++++ src/backend/controllers/events/limits.ts | 23 + .../controllers/events/workerDeploy.test.ts | 147 ++++ .../controllers/events/workerDeploy.ts | 366 ++++++++ src/backend/controllers/fs/FSController.ts | 8 + .../controllers/fs/LegacyFSController.ts | 15 +- src/backend/drivers/workers/WorkerDriver.ts | 229 +++-- src/backend/services/events/EventsService.ts | 332 +++++++- .../events/handlers.integration.test.ts | 51 ++ .../events/workerInvoker.integration.test.ts | 180 +++- .../events/workerRuntime.integration.test.ts | 779 ++++++++++++++++++ .../services/events/workerRuntime.test.ts | 72 ++ src/backend/services/events/workerRuntime.ts | 72 ++ src/backend/services/events/workerSeam.ts | 66 +- .../services/events/workerSource.test.ts | 503 +++++++++++ src/backend/services/events/workerSource.ts | 148 ++++ .../events/workers.integration.test.ts | 381 +++++++++ .../localworker/LocalWorkerService.ts | 86 +- .../EventHandlerStore.integration.test.ts | 197 +++++ .../stores/events/EventHandlerStore.ts | 180 ++++ src/backend/types.ts | 24 + src/docs/src/Events.md | 3 +- src/docs/src/Events/handlers.md | 5 +- src/docs/src/Events/onPersistent.md | 2 + src/docs/src/Events/workers.md | 87 ++ src/docs/src/rate-limits-and-quotas.md | 7 +- src/docs/src/sidebar.js | 8 + src/gui/src/UI/UIWindowManageSessions.js | 150 +++- src/gui/src/css/style.css | 36 + src/gui/src/helpers/eventsWorkers.js | 56 ++ src/gui/src/helpers/eventsWorkers.test.js | 105 +++ src/gui/src/i18n/translations/en.js | 7 + src/puter-js/index.d.ts | 5 + src/puter-js/src/modules/events/index.js | 7 + .../src/modules/events/lib/freeVariables.js | 18 +- .../modules/events/lib/freeVariables.test.js | 12 +- .../src/modules/events/lib/handlerSource.js | 28 +- .../modules/events/lib/handlerSource.test.js | 38 + .../src/modules/events/lib/handlers.js | 15 + .../src/modules/events/lib/workers.js | 80 ++ .../src/modules/events/lib/workers.test.js | 91 ++ src/puter-js/src/modules/events/types.js | 47 ++ src/puter-js/tests/api/suites/events.suite.ts | 81 ++ src/worker/scripts/buildPreamble.mjs | 16 +- src/worker/src/events-runtime.js | 178 ++++ src/worker/template/puter-events.template | 8 + .../template/puter-portable-core.template | 53 ++ src/worker/template/puter-portable.template | 49 +- 51 files changed, 5400 insertions(+), 302 deletions(-) create mode 100644 src/backend/clients/events/EventsWorkerInvokerClient.test.ts create mode 100644 src/backend/controllers/events/workerDeploy.test.ts create mode 100644 src/backend/controllers/events/workerDeploy.ts create mode 100644 src/backend/services/events/workerRuntime.integration.test.ts create mode 100644 src/backend/services/events/workerRuntime.test.ts create mode 100644 src/backend/services/events/workerRuntime.ts create mode 100644 src/backend/services/events/workerSource.test.ts create mode 100644 src/backend/services/events/workerSource.ts create mode 100644 src/backend/services/events/workers.integration.test.ts create mode 100644 src/docs/src/Events/workers.md create mode 100644 src/gui/src/helpers/eventsWorkers.js create mode 100644 src/gui/src/helpers/eventsWorkers.test.js create mode 100644 src/puter-js/src/modules/events/lib/handlerSource.test.js create mode 100644 src/puter-js/src/modules/events/lib/workers.js create mode 100644 src/puter-js/src/modules/events/lib/workers.test.js create mode 100644 src/worker/src/events-runtime.js create mode 100644 src/worker/template/puter-events.template create mode 100644 src/worker/template/puter-portable-core.template diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 414878e35..93300d655 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -474,6 +474,14 @@ export type EventMap = { worker: string; }; + // An app's events worker coming into being (its handler count going 0→1) + // and going away (1→0, whether by removing the last handler or by the + // destroy route). `actor.user` is the app's owner, not the caller — a + // developer session publishing for an app it owns is the common case, but + // billing follows ownership. + 'events.worker.create': { actor: Actor; appUid: string }; + 'events.worker.destroy': { actor: Actor; appUid: string }; + // ---- Outer / GUI broadcast ---- 'outer.cacheUpdate': { cacheKey: string[]; diff --git a/src/backend/clients/events/EventsWorkerInvokerClient.test.ts b/src/backend/clients/events/EventsWorkerInvokerClient.test.ts new file mode 100644 index 000000000..60a6d169b --- /dev/null +++ b/src/backend/clients/events/EventsWorkerInvokerClient.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * `DispatcherInvokeTransport` against an injected fetch, so the handled/error + * header contract is pinned without a real dispatcher on the other end. + */ + +import type { fetch as undiciFetch } from 'undici'; +import { describe, expect, it } from 'vitest'; +import { + DispatcherInvokeTransport, + EVENTS_DISPATCH_ERROR_HEADER, + EVENTS_DISPATCH_PATH, + EVENTS_ERROR_HEADER, + EVENTS_HANDLED_HEADER, +} from './EventsWorkerInvokerClient.js'; + +type FetchImpl = typeof undiciFetch; + +const CALL = { + script: 'evw-test', + appUid: 'app-test', + key: 'k1:test', + body: '{}', + timeoutMs: 5_000, +}; + +/** A stub fetch that answers the same way on every call. */ +const stubFetch = ( + status: number, + headers: Record = {}, +): FetchImpl => + (async () => + new Response(null, { status, headers })) as unknown as FetchImpl; + +/** A stub fetch that never answers until the abort signal fires. */ +const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new Error('This operation was aborted')), + ); + })) as unknown as FetchImpl; + +/** A stub fetch that records the URL it was called with. */ +const capturingFetch = ( + calls: string[], + status = 200, +): FetchImpl => + (async (url: string) => { + calls.push(url); + return new Response(null, { status }); + }) as unknown as FetchImpl; + +describe('DispatcherInvokeTransport', () => { + it('reports a handled 400 with no error — the handler`s own refusal', async () => { + const transport = new DispatcherInvokeTransport('http://dispatcher', 's', { + fetchImpl: stubFetch(400, { [EVENTS_HANDLED_HEADER]: '1' }), + }); + expect(await transport.send(CALL)).toEqual({ + status: 400, + handled: true, + }); + }); + + it('reports an unmarked 404 with no dispatch-error header as unhandled', async () => { + const transport = new DispatcherInvokeTransport('http://dispatcher', 's', { + fetchImpl: stubFetch(404), + }); + expect(await transport.send(CALL)).toEqual({ + status: 404, + handled: false, + }); + }); + + it('turns a dispatch-error header into a null status with the reason', async () => { + const transport = new DispatcherInvokeTransport('http://dispatcher', 's', { + fetchImpl: stubFetch(502, { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-failed' }), + }); + expect(await transport.send(CALL)).toEqual({ + status: null, + error: 'dispatcher: deploy-failed (502)', + }); + }); + + it('reports a handled 200 as settled with no error', async () => { + const transport = new DispatcherInvokeTransport('http://dispatcher', 's', { + fetchImpl: stubFetch(200, { [EVENTS_HANDLED_HEADER]: '1' }), + }); + expect(await transport.send(CALL)).toEqual({ + status: 200, + handled: true, + }); + }); + + it('passes a 429 through as-is', async () => { + const transport = new DispatcherInvokeTransport('http://dispatcher', 's', { + fetchImpl: stubFetch(429), + }); + expect(await transport.send(CALL)).toEqual({ + status: 429, + handled: false, + }); + }); + + it('answers null with an error when the request times out', async () => { + const transport = new DispatcherInvokeTransport('http://dispatcher', 's', { + fetchImpl: hangingFetch, + }); + const result = await transport.send({ ...CALL, timeoutMs: 10 }); + expect(result.status).toBeNull(); + expect(result.error).toMatch(/abort/i); + }); + + it('surfaces the runtime`s own error header alongside its status', async () => { + const transport = new DispatcherInvokeTransport('http://dispatcher', 's', { + fetchImpl: stubFetch(500, { + [EVENTS_HANDLED_HEADER]: '1', + [EVENTS_ERROR_HEADER]: 'handler-threw', + }), + }); + expect(await transport.send(CALL)).toEqual({ + status: 500, + handled: true, + error: 'handler-threw', + }); + }); + + it('preserves a path prefix on the dispatcher URL', async () => { + const calls: string[] = []; + const transport = new DispatcherInvokeTransport( + 'http://dispatcher/prefix/', + 's', + { fetchImpl: capturingFetch(calls) }, + ); + await transport.send(CALL); + expect(calls).toEqual([`http://dispatcher/prefix${EVENTS_DISPATCH_PATH}`]); + }); +}); diff --git a/src/backend/clients/events/EventsWorkerInvokerClient.ts b/src/backend/clients/events/EventsWorkerInvokerClient.ts index 4b4aaba0d..d03c3bf76 100644 --- a/src/backend/clients/events/EventsWorkerInvokerClient.ts +++ b/src/backend/clients/events/EventsWorkerInvokerClient.ts @@ -27,69 +27,109 @@ import { PuterClient } from '../types.js'; /** * The call that leaves the platform and runs an app's own code. * - * The protocol is fixed and lives here rather than in the events service - * because it is a wire format, not a delivery decision: one POST, one header, - * and a status code that says whether the handler took the delivery, refused - * it, or could not answer. + * Events workers are unreachable from the internet: deployed into their own + * dispatch namespace and reached only through the events dispatcher, which + * answers on its own hostname behind the internal secret. * - * POST /__events/invoke - * puter-auth: - * { handler, event, ctx } + * POST /invoke + * x-puter-internal-auth: + * x-puter-events-script: + + + +``` diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 5c0e59d85..91eed274a 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -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. diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index 550027868..630411ce4 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -462,6 +462,14 @@ let sidebar = [ source: '/Events/handlers.md', path: '/Events/handlers', }, + { + title: 'workers', + page_title: 'puter.events.workers', + title_tag: 'puter.events.workers', + icon: '/assets/img/function.svg', + source: '/Events/workers.md', + path: '/Events/workers', + }, ], }, { diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js index f3c55e2ad..1fc523793 100644 --- a/src/gui/src/UI/UIWindowManageSessions.js +++ b/src/gui/src/UI/UIWindowManageSessions.js @@ -22,6 +22,8 @@ // in-modal sheets instead of UIAlert windows, so nothing here depends on the // window system and there is no cross-window z-index juggling. +import { fetchAllEventsWorkers, eventsWorkerLabel } from '../helpers/eventsWorkers.js'; + // Hand-rolled UA → {browser, os} extractor. Covers Chrome/Edge/Firefox/ // Safari/Opera + Windows/macOS/iOS/Android/Linux. The backend already // has `ua-parser-js`; pulling it into the GUI bundle just for this @@ -763,16 +765,160 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { w_body_list.classList.add('session-manager-list-body'); w_body.appendChild(w_body_list); + // ===================================================================== + // Events workers section — apps that publish background event handlers. + // Hidden entirely on older SDKs that have no `puter.events.workers`. + // ===================================================================== + const workersClient = puter.events?.workers; + const hasWorkersApi = + !!workersClient && + typeof workersClient.list === 'function' && + typeof workersClient.destroy === 'function'; + + let cachedWorkers = []; + let w_workers_list = null; + + const handlerCountLabel = (count) => { + const n = Number(count) || 0; + return n === 1 + ? i18n('events_workers_handler_count_one', [], false) + : i18n('events_workers_handler_count_other', [String(n)], false); + }; + + const WorkerWidget = ({ worker }) => { + const el = document.createElement('div'); + el.classList.add('session-widget'); + el.dataset.appUid = worker.appUid; + + const el_row = document.createElement('div'); + el_row.classList.add('session-widget-row'); + el.appendChild(el_row); + + const el_icon = document.createElement('div'); + el_icon.classList.add('session-widget-icon'); + el_icon.innerHTML = ICONS.worker; + el_row.appendChild(el_icon); + + const el_main = document.createElement('div'); + el_main.classList.add('session-widget-main'); + + const el_titleline = document.createElement('div'); + el_titleline.classList.add('session-widget-titleline'); + const el_title = document.createElement('div'); + el_title.classList.add('session-widget-title'); + el_title.textContent = eventsWorkerLabel(worker) || worker.appUid; + el_titleline.appendChild(el_title); + el_main.appendChild(el_titleline); + + const el_meta = buildMetaLine([{ text: handlerCountLabel(worker.handlerCount) }]); + if ( el_meta ) el_main.appendChild(el_meta); + + el_row.appendChild(el_main); + + const el_actions = document.createElement('div'); + el_actions.classList.add('session-widget-actions'); + + const el_btn_destroy = document.createElement('button'); + el_btn_destroy.type = 'button'; + el_btn_destroy.classList.add('session-widget-revoke'); + el_btn_destroy.innerHTML = `${ICONS.trash}${i18n('events_workers_destroy')}`; + el_btn_destroy.title = i18n('events_workers_destroy'); + el_btn_destroy.addEventListener('click', async () => { + try { + const ok = await confirmDialog({ + message: i18n('confirm_events_worker_destroy'), + confirmLabel: i18n('events_workers_destroy'), + danger: true, + }); + if ( ! ok ) return; + + await workersClient.destroy(worker.appUid); + reload_workers(); + } catch ( e ) { + // SDK rejections are plain { message, code } objects, not Errors. + alertDialog({ message: e?.message ?? String(e) }); + } + }); + el_actions.appendChild(el_btn_destroy); + el_row.appendChild(el_actions); + + return { + appendTo (parent) { + parent.appendChild(el); + return this; + }, + }; + }; + + const render_workers = () => { + if ( ! w_workers_list ) return; + w_workers_list.replaceChildren(); + if ( cachedWorkers.length === 0 ) { + const el_empty = document.createElement('div'); + el_empty.classList.add('session-manager-count'); + el_empty.textContent = i18n('events_workers_none'); + w_workers_list.appendChild(el_empty); + return; + } + for ( const worker of cachedWorkers ) { + WorkerWidget({ worker }).appendTo(w_workers_list); + } + }; + + const reload_workers = async () => { + if ( ! hasWorkersApi ) return; + try { + cachedWorkers = await fetchAllEventsWorkers(workersClient); + } catch { + // Network flake — keep whatever's currently rendered. + return; + } + render_workers(); + }; + + if ( hasWorkersApi ) { + const el_workers_section = document.createElement('div'); + el_workers_section.classList.add('session-manager-workers-section'); + + const el_workers_head = document.createElement('div'); + el_workers_head.classList.add('session-manager-workers-head'); + + const el_workers_title = document.createElement('h3'); + el_workers_title.classList.add('session-manager-workers-title'); + el_workers_title.textContent = i18n('events_workers'); + el_workers_head.appendChild(el_workers_title); + + const el_workers_desc = document.createElement('p'); + el_workers_desc.classList.add('session-manager-workers-description'); + el_workers_desc.textContent = i18n('events_workers_description'); + el_workers_head.appendChild(el_workers_desc); + + el_workers_section.appendChild(el_workers_head); + + w_workers_list = document.createElement('div'); + w_workers_list.classList.add('session-manager-workers-list'); + el_workers_section.appendChild(w_workers_list); + + w_body.appendChild(el_workers_section); + } + reload_sessions(); + reload_workers(); // Two-tier refresh: // - focus → re-fetch immediately (cheapest signal that something // in the user's other tabs might have changed sessions). // - 60s fallback interval so a long-lived but unfocused modal // still eventually sees revocations propagate. - onFocus = () => reload_sessions(); + onFocus = () => { + reload_sessions(); + reload_workers(); + }; window.addEventListener('focus', onFocus); - interval = setInterval(reload_sessions, 60_000); + interval = setInterval(() => { + reload_sessions(); + reload_workers(); + }, 60_000); }; export default UIWindowManageSessions; diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 111162c60..924f1a079 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -5645,6 +5645,42 @@ html.dark-mode .usage-table-show-less:hover { background: var(--dashboard-content-background, #fcfcfd); } +/* -- Events workers section (below the session list) -- */ +.session-manager-workers-section { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--dashboard-border, #e0e0e0); + max-height: 260px; +} + +.session-manager-workers-head { + flex: 0 0 auto; +} + +.session-manager-workers-title { + margin: 0 0 4px; + font-size: 14px; + font-weight: 600; + color: var(--dashboard-text-primary, #1e293b); +} + +.session-manager-workers-description { + margin: 0 0 8px; + font-size: 12px; + color: var(--dashboard-text-muted, #94a3b8); +} + +.session-manager-workers-list { + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; +} + /* Touch devices have no hover — keep the row actions visible */ @media (hover: none), (pointer: coarse) { .session-widget-rename, diff --git a/src/gui/src/helpers/eventsWorkers.js b/src/gui/src/helpers/eventsWorkers.js new file mode 100644 index 000000000..93a175fe8 --- /dev/null +++ b/src/gui/src/helpers/eventsWorkers.js @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Page through `puter.events.workers.list({ limit, cursor })` until the + * server stops handing back a cursor, returning every item collected. + * + * `maxPages` only guards against a cursor that never terminates. + * + * @param {{ list?: (opts: { limit: number, cursor?: string }) => Promise<{ items: object[], cursor?: string }> }} [workersClient] + * @param {object} [opts] + * @param {number} [opts.limit] + * @param {number} [opts.maxPages] + * @returns {Promise} + */ +export const fetchAllEventsWorkers = async (workersClient, { limit = 100, maxPages = 50 } = {}) => { + if ( !workersClient || typeof workersClient.list !== 'function' ) return []; + + const items = []; + let cursor; + for ( let page = 0; page < maxPages; page++ ) { + const resp = await workersClient.list({ limit, cursor }); + const pageItems = Array.isArray(resp?.items) ? resp.items : []; + items.push(...pageItems); + cursor = resp?.cursor; + if ( !cursor || pageItems.length === 0 ) break; + } + return items; +}; + +/** + * Display name for an events worker row — the app's title, falling back to + * its internal name when no title is set. + * + * @param {{ appTitle?: string, appName?: string }} worker + * @returns {string} + */ +export const eventsWorkerLabel = (worker) => worker?.appTitle || worker?.appName || ''; + +export default fetchAllEventsWorkers; diff --git a/src/gui/src/helpers/eventsWorkers.test.js b/src/gui/src/helpers/eventsWorkers.test.js new file mode 100644 index 000000000..7f177b0ee --- /dev/null +++ b/src/gui/src/helpers/eventsWorkers.test.js @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, it, expect } from 'vitest'; +import { fetchAllEventsWorkers, eventsWorkerLabel } from './eventsWorkers.js'; + +const worker = (over = {}) => ({ + appUid: 'app-1111', + appName: 'my-worker-app', + appTitle: 'My Worker App', + handlerCount: 2, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + script: 'export default {};', + deployable: true, + ...over, +}); + +describe('eventsWorkerLabel', () => { + it('prefers appTitle', () => { + expect(eventsWorkerLabel(worker())).toBe('My Worker App'); + }); + + it('falls back to appName when there is no title', () => { + expect(eventsWorkerLabel(worker({ appTitle: undefined }))).toBe('my-worker-app'); + }); + + it('returns an empty string for a missing worker', () => { + expect(eventsWorkerLabel(null)).toBe(''); + expect(eventsWorkerLabel({})).toBe(''); + }); +}); + +// Minimal `puter.events.workers` double: paginate a fixed item set by cursor. +const makeWorkersClient = (pages) => { + const calls = []; + return { + calls, + list: async ({ limit, cursor }) => { + calls.push({ limit, cursor }); + const index = cursor ? Number(cursor) : 0; + return pages[index] ?? { items: [], cursor: undefined }; + }, + }; +}; + +describe('fetchAllEventsWorkers', () => { + it('returns [] when the client is missing (older SDK)', async () => { + expect(await fetchAllEventsWorkers(undefined)).toEqual([]); + expect(await fetchAllEventsWorkers({})).toEqual([]); + }); + + it('returns every item from a single page', async () => { + const client = makeWorkersClient([ + { items: [worker({ appUid: 'a' }), worker({ appUid: 'b' })], cursor: undefined }, + ]); + const items = await fetchAllEventsWorkers(client); + expect(items.map((w) => w.appUid)).toEqual(['a', 'b']); + expect(client.calls).toEqual([{ limit: 100, cursor: undefined }]); + }); + + it('follows the cursor across pages until one comes back empty', async () => { + const client = makeWorkersClient([ + { items: [worker({ appUid: 'a' })], cursor: '1' }, + { items: [worker({ appUid: 'b' })], cursor: '2' }, + { items: [], cursor: undefined }, + ]); + const items = await fetchAllEventsWorkers(client, { limit: 1 }); + expect(items.map((w) => w.appUid)).toEqual(['a', 'b']); + expect(client.calls).toEqual([ + { limit: 1, cursor: undefined }, + { limit: 1, cursor: '1' }, + { limit: 1, cursor: '2' }, + ]); + }); + + it('stops at maxPages against a cursor that never runs out', async () => { + const client = { + list: async () => ({ items: [worker()], cursor: 'always-more' }), + }; + const items = await fetchAllEventsWorkers(client, { maxPages: 3 }); + expect(items).toHaveLength(3); + }); + + it('tolerates a malformed response (no items array)', async () => { + const client = { list: async () => ({}) }; + expect(await fetchAllEventsWorkers(client)).toEqual([]); + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 54d660947..130a0215b 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -110,6 +110,7 @@ const en = { confirm_delete_user: 'Are you sure you want to delete your account? All your files and data will be permanently deleted. This action cannot be undone.', confirm_delete_user_title: 'Delete Account?', confirm_session_revoke: 'Are you sure you want to revoke this session?', + confirm_events_worker_destroy: 'Are you sure you want to destroy this background worker? Its published event handlers will be removed, and any subscriptions using them will be suspended.', confirm_revoke_all_other_sessions: 'Revoke all other sessions? You will stay signed in here.', confirm_your_email_address: 'Confirm Your Email Address', choose_publishing_option: 'Choose how you want to publish your website:', @@ -181,6 +182,12 @@ const en = { error_message_is_missing: 'Error message is missing.', error_unknown_cause: 'An unknown error occurred.', error_uploading_files: 'Failed to upload files', + events_workers: 'Background workers', + events_workers_description: 'Apps that run event handlers for you in the background. Destroying one removes its published handlers; subscriptions using them are suspended.', + events_workers_destroy: 'Destroy', + events_workers_handler_count_one: '1 handler', + events_workers_handler_count_other: '%% handlers', + events_workers_none: 'No background workers.', favorites: 'Favorites', feedback: 'Feedback', feedback_c2a: 'Please use the form below to send us your feedback, comments, and bug reports.', diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index e6ed6d5d1..532338b8a 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -94,12 +94,16 @@ export type { // -- puter.events -- export type { + DestroyedEventsWorker, EventAnchor, EventDelivery, EventFetchOptions, EventFetchPage, EventGapMarker, EventHandler, + EventsWorkerPage, + EventsWorkerSummary, + EventsWorkersListOptions, HandlerOptions, HandlerPublication, HandlerSummary, @@ -113,6 +117,7 @@ export type { } from './types/modules/events/types.js'; export type { EventSubscription } from './types/modules/events/lib/subscription.js'; export type { EventHandlers } from './types/modules/events/lib/handlers.js'; +export type { EventsWorkers } from './types/modules/events/lib/workers.js'; // -- puter.fs -- export type { diff --git a/src/puter-js/src/modules/events/index.js b/src/puter-js/src/modules/events/index.js index 76a9756e7..77dca250e 100644 --- a/src/puter-js/src/modules/events/index.js +++ b/src/puter-js/src/modules/events/index.js @@ -1,6 +1,7 @@ import { PuterModule } from '../../lib/PuterModule.js'; import { EventChannel } from './lib/channel.js'; import { EventHandlers } from './lib/handlers.js'; +import { EventsWorkers } from './lib/workers.js'; import { fetch } from './fetch.js'; import { list } from './list.js'; import { onLocal } from './onLocal.js'; @@ -22,6 +23,9 @@ import { unsubscribe } from './unsubscribe.js'; * `fetch()` is the other half: what happened while nothing was listening, read * from the subject's own store a page at a time. * + * A published handler set stands up an **events worker** per app — + * `puter.events.workers` is where an owner sees and destroys them. + * * Method implementations live in the sibling files as `this`-context functions * whose JSDoc is the source of truth for the public signatures — `types/` is * generated from it, never edited by hand. @@ -49,6 +53,9 @@ export class EventsModule extends PuterModule { /** The named functions this app has deployed. */ this.handlers = new EventHandlers(this); + /** The events worker a published handler set implies. */ + this.workers = new EventsWorkers(this); + const methods = /** @type {Record unknown>} */ ( /** @type {unknown} */ (this) ); diff --git a/src/puter-js/src/modules/events/lib/freeVariables.js b/src/puter-js/src/modules/events/lib/freeVariables.js index ae47d3c2d..5d75d97a4 100644 --- a/src/puter-js/src/modules/events/lib/freeVariables.js +++ b/src/puter-js/src/modules/events/lib/freeVariables.js @@ -67,16 +67,24 @@ export const HANDLER_GLOBALS = new Set([ 'crypto', 'Crypto', 'SubtleCrypto', 'performance', 'WebSocket', 'Event', 'EventTarget', 'CustomEvent', 'MessageChannel', 'MessagePort', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', - // The SDK the worker runs inside. - 'puter', ]); +/** + * Names an events worker deliberately does not provide, and what to use + * instead. A handler runs with no ambient SDK: there is no account it belongs + * to until a delivery says whose it is. + */ +const NO_AMBIENT_SDK = new Set(['puter', 'me', 'my', 'myself']); + /** Raised for the identifier that could not be resolved, naming it. */ const freeVariable = (name) => new PuterJSError( - `Handler refers to \`${name}\`, which is not a parameter, a local, or a known global. ` + - 'A handler is serialized and run elsewhere, so it cannot close over anything — ' + - 'pass the value in `context` and read it from `ctx`.', + NO_AMBIENT_SDK.has(name) + ? `Handler refers to \`${name}\`, and a handler has no ambient SDK: ` + + 'it runs as whoever the delivery belongs to. Use the `user` binding.' + : `Handler refers to \`${name}\`, which is not a parameter, a local, or a known global. ` + + 'A handler is serialized and run elsewhere, so it cannot close over anything — ' + + 'pass the value in `context` and read it from `ctx`.', 'events_handler_free_variable', ); diff --git a/src/puter-js/src/modules/events/lib/freeVariables.test.js b/src/puter-js/src/modules/events/lib/freeVariables.test.js index cc47c19af..7b97a3700 100644 --- a/src/puter-js/src/modules/events/lib/freeVariables.test.js +++ b/src/puter-js/src/modules/events/lib/freeVariables.test.js @@ -42,7 +42,7 @@ const ACCEPTED = [ ['a comment naming something undeclared', '({ event }) => { /* endpoint is gone now */ return event.uid; }'], ['a string naming something undeclared', '({ event }) => event.path + "endpoint"'], ['runtime globals', '({ event }) => { console.log(Date.now(), Math.max(1, event.seq), JSON.stringify(event), new URL("https://x.example")); }'], - ['the SDK global a worker runs inside', '({ user }) => user.puter.fs.read("/x").then(r => puter.print(r))'], + ['the delivered puter, reached through `user`', '({ user }) => user.fs.read("/x").then(r => user.print(r))'], ['optional chaining and computed member access', '({ event, ctx }) => event?.meta?.[ctx.key]'], ['a shorthand method on an object literal', '({ event }) => ({ run (x) { return x + event.seq; } })'], ['an async generator with a yield', 'async function* ({ ctx }) { yield ctx.first; }'], @@ -92,6 +92,10 @@ const REJECTED = [ // above, where `endpoint` is a label nothing needs to resolve. ['a closure read through object shorthand', '({ event }) => ({ endpoint, path: event.path })', 'endpoint'], ['typeof on an undeclared name', '({ event }) => typeof missingGlobal === "undefined" ? event.seq : 0', 'missingGlobal'], + // An events worker has no ambient SDK, so a handler that reaches for one + // has to be caught here rather than on its first delivery. + ['the ambient SDK a client has and a worker does not', '({ event }) => puter.print(event.path)', 'puter'], + ['the worker`s own identity', '({ event }) => me.puter.fs.write(event.path, "x")', 'me'], ]; describe('handlers a scan accepts', () => { @@ -107,6 +111,12 @@ describe('handlers a scan rejects', () => { expect(error.message).toContain(`\`${identifier}\``); }); + it('points an ambient-SDK reference at the binding that replaces it', () => { + const error = rejects('({ event }) => puter.print(event.path)'); + expect(error.code).toBe('events_handler_free_variable'); + expect(error.message).toContain('`user`'); + }); + it('names the identifier so the developer knows what to move into context', () => { const error = rejects('({ event }) => fetch(ingestUrl, { body: event.path })'); expect(error.message).toContain('`ingestUrl`'); diff --git a/src/puter-js/src/modules/events/lib/handlerSource.js b/src/puter-js/src/modules/events/lib/handlerSource.js index ba08900b2..de41e7f6a 100644 --- a/src/puter-js/src/modules/events/lib/handlerSource.js +++ b/src/puter-js/src/modules/events/lib/handlerSource.js @@ -57,6 +57,31 @@ export const hashSource = async (source) => { .join(''); }; +// A method defined with shorthand syntax (`ingest({ event }) { … }` in an +// object literal or a class) stringifies without the `function` keyword, and +// so is not an expression: the worker would bake it as a broken stub. Every +// other form — `function`, `async function`, arrows, `class` — already is one. +const METHOD_SHORTHAND = /^(async\s+)?(\*\s*)?([A-Za-z_$][\w$]*|\[[^\]]*\])\s*\(/; +const NOT_SHORTHAND = /^(async\s+)?(function\b|class\b|\()/; + +/** + * A function's source as something that parses on its own. Shorthand methods + * get the keyword they stringified without; anything else is returned as is. + * + * @param {string} source + * @returns {string} + */ +export const asExpression = (source) => { + const trimmed = source.trim(); + if ( NOT_SHORTHAND.test(trimmed) || ! METHOD_SHORTHAND.test(trimmed) ) return source; + const isAsync = /^async\s+/.test(trimmed); + const rest = trimmed.replace(/^async\s+/, ''); + // A getter, setter or computed name cannot be turned into a plain function + // by hand — leave it for the server-side check to refuse. + if ( /^(get|set)\s+[A-Za-z_$[]/.test(rest) || rest.startsWith('[') ) return source; + return `${isAsync ? 'async ' : ''}function ${rest}`; +}; + /** * The source of a handler given as a function or a source string. A * `{ file }` form is read separately, because reading is asynchronous and @@ -66,7 +91,8 @@ export const hashSource = async (source) => { * @returns {string | null} `null` when the handler is a `{ file }` reference. */ export const sourceOf = (handler) => { - if ( typeof handler === 'function' ) return Function.prototype.toString.call(handler); + if ( typeof handler === 'function' ) + return asExpression(Function.prototype.toString.call(handler)); if ( typeof handler === 'string' ) { if ( handler.trim().length === 0 ) throw invalidHandler('A handler source string may not be empty'); diff --git a/src/puter-js/src/modules/events/lib/handlerSource.test.js b/src/puter-js/src/modules/events/lib/handlerSource.test.js new file mode 100644 index 000000000..083a6f974 --- /dev/null +++ b/src/puter-js/src/modules/events/lib/handlerSource.test.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { asExpression, sourceOf } from './handlerSource.js'; + +describe('sourceOf', () => { + it('leaves function expressions, arrows and classes alone', () => { + const cases = [ + async ({ event }) => event, + ({ event }) => event, + (x) => x, + async function ingest({ event }) { return event; }, + function ingest() {}, + class Handler {}, + ]; + for ( const fn of cases ) + expect(sourceOf(fn)).toBe(Function.prototype.toString.call(fn)); + }); + + it('gives a shorthand method the keyword it stringified without', () => { + const handlers = { + async ingest({ event, ack }) { await ack(); return event; }, + plain(event) { return event; }, + $ok_1() {}, + }; + for ( const [name, fn] of Object.entries(handlers) ) { + const source = sourceOf(fn); + expect(source.startsWith(fn.constructor.name === 'AsyncFunction' ? 'async function ' : 'function ')).toBe(true); + expect(source).toContain(`${name}(`); + // The point: it now parses as an expression on its own. + expect(() => new Function(`return (\n${source}\n);`)).not.toThrow(); + } + }); + + it('does not touch what it cannot fix', () => { + expect(asExpression('get value() { return 1; }')).toBe('get value() { return 1; }'); + expect(asExpression('[computed]() {}')).toBe('[computed]() {}'); + expect(asExpression('x => x')).toBe('x => x'); + }); +}); diff --git a/src/puter-js/src/modules/events/lib/handlers.js b/src/puter-js/src/modules/events/lib/handlers.js index f61d5369c..96f772d72 100644 --- a/src/puter-js/src/modules/events/lib/handlers.js +++ b/src/puter-js/src/modules/events/lib/handlers.js @@ -130,6 +130,21 @@ export class EventHandlers { return removed; } + /** + * @internal Drop every cached publish base for an app, after something + * removed its whole set. Sending a base for a name that is gone is what + * makes the next publish look like a lost race. The implicit-app keys go + * too: those are an app token's own app, which is the only app it can + * have emptied. + * @param {string} appUid + */ + forget (appUid) { + for ( const key of [...this.known.keys()] ) { + if ( key.startsWith(`${appUid}|`) || key.startsWith('|') ) + this.known.delete(key); + } + } + /** * @internal Serialize, scan and send one or more publications, then record * what is now published so the next publish can name its base. diff --git a/src/puter-js/src/modules/events/lib/workers.js b/src/puter-js/src/modules/events/lib/workers.js new file mode 100644 index 000000000..c1e016b01 --- /dev/null +++ b/src/puter-js/src/modules/events/lib/workers.js @@ -0,0 +1,80 @@ +import { PuterJSError } from '../../../lib/PuterJSError.js'; +import { request } from './api.js'; + +/** @typedef {import('../types.js').EventsWorkersListOptions} EventsWorkersListOptions */ +/** @typedef {import('../types.js').EventsWorkerPage} EventsWorkerPage */ +/** @typedef {import('../types.js').DestroyedEventsWorker} DestroyedEventsWorker */ + +/** + * `puter.events.workers` — the per-app events worker a published handler set + * implies. + * + * An app's events worker exists once it has at least one published handler, + * and goes away with the last one — whether that is removing handlers one by + * one or calling `destroy()` here. A hosted deployment may bill it as a + * standing cost per app, which is what this surface exists for: an account + * needs somewhere to see and stop paying for one. + * + * Account-scoped, unlike `puter.events.handlers`: this is the caller's own + * view of what it is running, across every app it owns, so it takes no + * `appUid` on `list()` and an app token cannot act here on its owner's behalf. + */ + +const invalidAppUid = () => + new PuterJSError( + '`appUid` must be a non-empty string', + 'invalid_request', + ); + +export class EventsWorkers { + /** @param {import('../index.js').EventsModule} module */ + constructor (module) { + /** @internal */ + this.module = module; + + for ( const name of ['list', 'destroy'] ) { + this[name] = this[name].bind(this); + } + } + + /** + * The caller's own events workers, one per app it owns with at least one + * published handler. + * + * @param {EventsWorkersListOptions} [options] + * @returns {Promise} + */ + async list (options = {}) { + const response = await request( + this.module.puter, + '/events/workers', + undefined, + { + ...(options.limit !== undefined ? { limit: options.limit } : {}), + ...(options.cursor !== undefined ? { cursor: options.cursor } : {}), + }, + ); + return /** @type {EventsWorkerPage} */ ({ + items: Array.isArray(response.items) ? response.items : [], + ...(typeof response.cursor === 'string' ? { cursor: response.cursor } : {}), + deployable: response.deployable === true, + }); + } + + /** + * Removes every handler an app has published, taking its events worker + * down with the last one. Subscriptions bound to them are **suspended**, + * the same as removing each by name — never deleted. + * + * @param {string} appUid + * @returns {Promise} + */ + async destroy (appUid) { + if ( typeof appUid !== 'string' || appUid.trim().length === 0 ) throw invalidAppUid(); + const destroyed = /** @type {DestroyedEventsWorker} */ ( + await request(this.module.puter, '/events/workers/destroy', { appUid }) + ); + this.module.handlers?.forget(appUid); + return destroyed; + } +} diff --git a/src/puter-js/src/modules/events/lib/workers.test.js b/src/puter-js/src/modules/events/lib/workers.test.js new file mode 100644 index 000000000..cf8f43839 --- /dev/null +++ b/src/puter-js/src/modules/events/lib/workers.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Every verb goes through the one HTTP helper, so mocking it needs no server. +const mockRequest = vi.fn(); +vi.mock('./api.js', () => ({ + request: (...args) => mockRequest(...args), +})); + +const { EventsWorkers } = await import('./workers.js'); +const { EventHandlers } = await import('./handlers.js'); + +const makeModule = () => { + const module = { puter: { APIOrigin: 'https://api.test' } }; + return new EventsWorkers(module); +}; + +const bodyOf = (index = 0) => mockRequest.mock.calls[index][2]; +const queryOf = (index = 0) => mockRequest.mock.calls[index][3]; +const routeOf = (index = 0) => mockRequest.mock.calls[index][1]; + +beforeEach(() => { + mockRequest.mockReset(); + mockRequest.mockResolvedValue({}); +}); + +describe('list', () => { + it('reads /events/workers with no query by default', async () => { + mockRequest.mockResolvedValue({ items: [], deployable: true }); + await makeModule().list(); + + expect(routeOf()).toBe('/events/workers'); + expect(bodyOf()).toBeUndefined(); + expect(queryOf()).toEqual({}); + }); + + it('forwards limit and cursor as query params', async () => { + await makeModule().list({ limit: 10, cursor: 'abc' }); + expect(queryOf()).toEqual({ limit: 10, cursor: 'abc' }); + }); + + it('normalizes the page, defaulting items and dropping an absent cursor', async () => { + mockRequest.mockResolvedValue({ items: [{ appUid: 'app-1' }], deployable: false }); + const page = await makeModule().list(); + + expect(page).toEqual({ items: [{ appUid: 'app-1' }], deployable: false }); + expect('cursor' in page).toBe(false); + }); + + it('carries a cursor through when the server returns one', async () => { + mockRequest.mockResolvedValue({ items: [], cursor: 'next', deployable: true }); + const page = await makeModule().list(); + expect(page.cursor).toBe('next'); + }); + + it('treats a missing deployable as false', async () => { + mockRequest.mockResolvedValue({ items: [] }); + const page = await makeModule().list(); + expect(page.deployable).toBe(false); + }); +}); + +describe('destroy', () => { + it('posts appUid to /events/workers/destroy', async () => { + mockRequest.mockResolvedValue({ appUid: 'app-1', removed: 2, suspended: 1 }); + const result = await makeModule().destroy('app-1'); + + expect(routeOf()).toBe('/events/workers/destroy'); + expect(bodyOf()).toEqual({ appUid: 'app-1' }); + expect(result).toEqual({ appUid: 'app-1', removed: 2, suspended: 1 }); + }); + + it('rejects client-side for a non-string appUid, without calling the server', async () => { + for ( const bad of [undefined, '', ' ', 42] ) { + await expect(makeModule().destroy(bad)).rejects.toMatchObject({ + code: 'invalid_request', + }); + } + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('drops the destroyed app`s cached publish bases, and keeps other apps`', async () => { + const module = { puter: { APIOrigin: 'https://api.test' }, handlers: new EventHandlers({}) }; + module.handlers.known.set('app-1|a', 'hash-a'); + module.handlers.known.set('|b', 'hash-b'); + module.handlers.known.set('app-2|c', 'hash-c'); + + await new EventsWorkers(module).destroy('app-1'); + + expect([...module.handlers.known.keys()]).toEqual(['app-2|c']); + }); +}); diff --git a/src/puter-js/src/modules/events/types.js b/src/puter-js/src/modules/events/types.js index cc8c7ccd4..9b348f1ed 100644 --- a/src/puter-js/src/modules/events/types.js +++ b/src/puter-js/src/modules/events/types.js @@ -270,3 +270,50 @@ * @property {number} subscriptions How many subscriptions are bound to this * name, suspended ones included. */ + +/** + * Options for `puter.events.workers.list()`. + * + * @typedef {Object} EventsWorkersListOptions + * @property {number} [limit] Apps per page. + * @property {string} [cursor] The `cursor` from a previous page. Absent starts + * from the first page. + */ + +/** + * One app of the caller's with published handlers — and so with an events + * worker standing behind them. + * + * @typedef {Object} EventsWorkerSummary + * @property {string} appUid + * @property {string} appName + * @property {string} appTitle + * @property {number} handlerCount How many handlers this app has published. + * @property {number} createdAt When this app's events worker first came into + * being — its earliest published handler. Unix seconds. + * @property {number} updatedAt Its most recently published or updated handler. + * Unix seconds. + * @property {string} script The deployed script name, for support/diagnosis. + */ + +/** + * One page of `puter.events.workers.list()`. + * + * @typedef {Object} EventsWorkerPage + * @property {EventsWorkerSummary[]} items + * @property {string} [cursor] Pass to read the next page. Absent means there + * is no next page. + * @property {boolean} deployable Whether this server actually deploys events + * workers. `false` on a self-hosted install without the runtime turned on — + * apps can still publish handlers, but nothing runs a background delivery. + */ + +/** + * What `puter.events.workers.destroy()` reports back. + * + * @typedef {Object} DestroyedEventsWorker + * @property {string} appUid + * @property {number} removed How many handlers were deleted. + * @property {number} suspended How many subscriptions were suspended as a + * result, across all of them. + */ diff --git a/src/puter-js/tests/api/suites/events.suite.ts b/src/puter-js/tests/api/suites/events.suite.ts index 504cb789c..e704049a2 100644 --- a/src/puter-js/tests/api/suites/events.suite.ts +++ b/src/puter-js/tests/api/suites/events.suite.ts @@ -772,6 +772,87 @@ export default suite('events', { t.assert.equal((await t.puter.events.handlers.list({ appUid })).length, 2); }, + // -- Events workers ----------------------------------------------- + + 'exposes the workers surface': async (t) => { + for (const method of ['list', 'destroy'] as const) { + t.assert.equal( + typeof t.puter.events.workers[method], + 'function', + `puter.events.workers.${method} is a function`, + ); + } + }, + + 'lists the events worker a first publish stood up, and destroys it': async (t) => { + const appUid = await makeApp(t); + await t.puter.events.handlers.publish('ingestUpload', HANDLER, { appUid }); + await t.puter.events.handlers.publish('indexDocument', OTHER_HANDLER, { + appUid, + }); + + const page = await t.puter.events.workers.list(); + const worker = page.items.find((row) => row.appUid === appUid); + t.assert.ok(worker, 'the app with published handlers is listed'); + t.assert.equal(worker?.handlerCount, 2); + t.assert.equal(typeof worker?.script, 'string'); + t.assert.equal(typeof page.deployable, 'boolean'); + + const destroyed = await t.puter.events.workers.destroy(appUid); + t.assert.equal(destroyed.appUid, appUid); + t.assert.equal(destroyed.removed, 2); + + const after = await t.puter.events.workers.list(); + t.assert.equal( + after.items.find((row) => row.appUid === appUid), + undefined, + 'destroying removes it from the listing', + ); + t.assert.deepEqual(await t.puter.events.handlers.list({ appUid }), []); + + // Destroying dropped the publish bases this client had cached, so a + // fresh publish reads as a create rather than as a lost race. + const republished = await t.puter.events.handlers.publish( + 'ingestUpload', + HANDLER, + { appUid }, + ); + t.assert.equal(republished.name, 'ingestUpload'); + }, + + 'refuses to destroy an app with no published handlers': async (t) => { + const appUid = await makeApp(t); + const error = await t.assert.rejects(() => + t.puter.events.workers.destroy(appUid), + ); + t.assert.equal(codeOf(error), 'events_handler_not_found'); + }, + + 'refuses an app token trying to list events workers': async (t) => { + const appUid = await makeApp(t); + await t.puter.events.handlers.publish('ingestUpload', HANDLER, { appUid }); + + await asApp(t, appUid, async () => { + const error = await t.assert.rejects(() => t.puter.events.workers.list()); + t.assert.equal(codeOf(error), 'events_worker_owner_only'); + }); + }, + + 'refuses to destroy an app the caller does not own': async (t) => { + const appUid = await makeApp(t); + await t.puter.events.handlers.publish('ingestUpload', HANDLER, { appUid }); + + await asApp(t, appUid, async () => { + // An app token names its own app; a second app's token would be + // this account's own to make, so a foreign uid is what stands in + // for "not owned" here. + const error = await t.assert.rejects(() => + t.puter.events.workers.destroy(unique('not-owned')), + ); + t.assert.equal(codeOf(error), 'events_handler_forbidden'); + }); + }, + // -- fetch ------------------------------------------------------ // // Catching up is a query against the subject's own store, so these need no diff --git a/src/worker/scripts/buildPreamble.mjs b/src/worker/scripts/buildPreamble.mjs index 94057dd98..cb815529d 100644 --- a/src/worker/scripts/buildPreamble.mjs +++ b/src/worker/scripts/buildPreamble.mjs @@ -5,9 +5,13 @@ import { fileURLToPath } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const workerDir = path.resolve(scriptDir, '..'); -const templatePath = path.join(workerDir, 'template', 'puter-portable.template'); const outputDir = path.join(workerDir, 'dist'); -const outputPath = path.join(outputDir, 'workerPreamble.js'); +// One preamble per worker runtime: the ordinary router one, and the events one +// that runs published handlers and holds no token of its own. +const runtimes = [ + ['puter-portable.template', 'workerPreamble.js'], + ['puter-events.template', 'eventsWorkerPreamble.js'], +]; // Build a version stamp: puter-js version + short git SHA const puterJsPkg = JSON.parse( @@ -48,5 +52,9 @@ const inlineIncludes = async (filePath) => { await mkdir(outputDir, { recursive: true }); const versionBanner = `var __PUTER_PREAMBLE_VERSION__ = ${JSON.stringify(preambleVersion)};\n`; -const preambleSource = await inlineIncludes(templatePath); -await writeFile(outputPath, versionBanner + preambleSource); +for (const [template, output] of runtimes) { + const preambleSource = await inlineIncludes( + path.join(workerDir, 'template', template), + ); + await writeFile(path.join(outputDir, output), versionBanner + preambleSource); +} diff --git a/src/worker/src/events-runtime.js b/src/worker/src/events-runtime.js new file mode 100644 index 000000000..015af7cd0 --- /dev/null +++ b/src/worker/src/events-runtime.js @@ -0,0 +1,178 @@ +/* + * The events worker runtime: prepended to an app's generated handler code, and + * the whole of what that code runs inside. Import-free so it can be inlined + * into `template/puter-events.template` as-is. + * + * Unlike the router runtime it has no `router` and no `me` — no worker token + * is deployed with an events worker, so a handler acts as the subscriber whose + * delivery it is running, from the token on the invocation. + * + * Every answer carries `x-puter-events-handled: 1`, built from a `Response` + * captured before handler code can run — so a handler cannot spoof it — and, + * on the runtime's own failure answers, a machine-readable `x-puter-events-error` + * naming which one: `bad-key`, `bad-body`, `handler-broken`, `unknown-handler`, + * `no-token`, `handler-threw`, `handler-terminal`. + * + * An unauthorized invocation answers 500 rather than 401: the only way to get + * one is a platform-side fault (a key rotated under a resident script), which + * a redeploy fixes, and a 4xx would retire the delivery instead. + */ + +(() => { + 'use strict'; + + // Captured before any handler code can run, so a handler cannot forge the + // handled header by reassigning the global `Response`. + const NativeResponse = Response; + + const INVOKE_PATH = '/__events/invoke'; + const HANDLED_HEADER = 'x-puter-events-handled'; + const ERROR_HEADER = 'x-puter-events-error'; + + const handlers = Object.create(null); + const broken = Object.create(null); + + /** What generated code registers into. The only global the runtime adds. */ + globalThis.__puterEvents = Object.freeze({ + register(name, fn) { + handlers[name] = fn; + }, + /** Published, but its stored source does not parse as a function. */ + markBroken(name) { + broken[name] = true; + }, + }); + + // Taken out of the global scope before any handler code has run, so the + // key cannot be read back out of the isolate by the app's own handlers. + // Reaching the dispatcher needs a separate secret this worker never sees, + // so a leaked key is not by itself an invocation — but there is no reason + // for it to be readable. + const invokeKey = + typeof globalThis.events_invoke_key === 'string' + ? globalThis.events_invoke_key + : ''; + try { + delete globalThis.events_invoke_key; + } catch { + globalThis.events_invoke_key = undefined; + } + + const apiOrigin = globalThis.puter_endpoint || 'https://api.puter.com'; + + /** + * Every answer is provably this runtime's own — built from the `Response` + * captured before handler code ran — and carries the handled header. + * `errorCode` names one of the runtime's own failure modes; a handler's + * own 2xx/4xx/500 carries none. + */ + const answer = (status, body, errorCode) => { + const headers = { + 'content-type': 'application/json', + [HANDLED_HEADER]: '1', + }; + if (errorCode) headers[ERROR_HEADER] = errorCode; + return new NativeResponse(JSON.stringify(body), { status, headers }); + }; + + /** + * Constant-time compare, so the key cannot be recovered a byte at a time + * from how long the comparison took. `timingSafeEqual` is not in this + * runtime. + */ + const keysEqual = (a, b) => { + if (typeof a !== 'string' || typeof b !== 'string') return false; + if (a.length !== b.length || a.length === 0) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; + }; + + const handle = async (request) => { + if ( + request.method !== 'POST' || + new URL(request.url).pathname !== INVOKE_PATH + ) { + return answer(404, { error: 'not an invocation' }); + } + if ( + !keysEqual( + request.headers.get('x-puter-events-key') ?? '', + invokeKey, + ) + ) { + return answer( + 500, + { error: 'not an authorized invocation' }, + 'bad-key', + ); + } + + let body = null; + try { + body = await request.json(); + } catch { + /* answered below */ + } + if (!body || typeof body !== 'object') { + return answer(400, { error: 'body must be a JSON object' }, 'bad-body'); + } + + const name = typeof body.handler === 'string' ? body.handler : ''; + // Published but not runnable: retriable, so republishing a fixed + // source is picked up rather than the delivery being dropped. + if (broken[name]) + return answer( + 500, + { error: 'handler failed to load' }, + 'handler-broken', + ); + const run = handlers[name]; + if (!run) + return answer(404, { error: 'unknown handler' }, 'unknown-handler'); + + // Nothing to run the handler as. Platform-side, so retriable. + const token = typeof body.token === 'string' ? body.token : ''; + if (!token) + return answer(500, { error: 'missing delivery token' }, 'no-token'); + + // `ack()` marks the delivery taken; a later throw does not unsay it. + let acked = false; + const ack = () => { + acked = true; + return Promise.resolve(); + }; + const ctx = Object.freeze( + body.ctx === null || body.ctx === undefined ? {} : body.ctx, + ); + + try { + await run({ + event: body.event, + ctx, + user: init_puter_portable(token, apiOrigin, 'userPuter'), + fetch: globalThis.fetch.bind(globalThis), + ack, + }); + return answer(200, { ok: true }); + } catch (err) { + if (acked) return answer(200, { ok: true }); + const terminal = + !!err && + (err.terminal === true || err.code === 'events_terminal'); + const message = + err && err.message ? String(err.message) : String(err); + return answer( + terminal ? 400 : 500, + { error: message }, + terminal ? 'handler-terminal' : 'handler-threw', + ); + } + }; + + self.addEventListener('fetch', (event) => { + event.respondWith(handle(event.request)); + }); +})(); diff --git a/src/worker/template/puter-events.template b/src/worker/template/puter-events.template new file mode 100644 index 000000000..84964e95c --- /dev/null +++ b/src/worker/template/puter-events.template @@ -0,0 +1,8 @@ +// The events worker runtime: puter.js, and a fetch handler that answers +// exactly one platform route. No `router` and no `me` — an events worker runs +// published handlers with the arguments it is given and holds no token of its +// own. Handled separately from the webpack project. + +#include "./puter-portable-core.template" + +#include "../src/events-runtime.js" diff --git a/src/worker/template/puter-portable-core.template b/src/worker/template/puter-portable-core.template new file mode 100644 index 000000000..357216719 --- /dev/null +++ b/src/worker/template/puter-portable-core.template @@ -0,0 +1,53 @@ +// Shared by every worker preamble: the Cloudflare EventTarget fix and +// `init_puter_portable`, which builds a puter.js client around one token. +// Included by `puter-portable.template` (the router runtime, which also gets a +// `me` built from the deployed worker's own token) and by +// `puter-events.template` (the events runtime, which has no such token). +// +// This file is not actually in the webpack project, it is handled separately. +if (globalThis.Cloudflare) { + // Cloudflare Workers has a faulty EventTarget implementation which doesn't + // bind "this" to the event handler. + // https://github.com/cloudflare/workerd/issues/4453 + const CfEventTarget = EventTarget; + globalThis.EventTarget = class EventTarget extends CfEventTarget { + constructor(...args) { + super(...args); + } + + addEventListener(type, listener, options) { + super.addEventListener(type, listener.bind(this), options); + } + }; +} + +globalThis.init_puter_portable = (auth, apiOrigin, type) => { + if (type === 'userPuter') { + const goodContext = {}; + Object.getOwnPropertyNames(globalThis).forEach((name) => { + try { + goodContext[name] = globalThis[name]; + } catch {} + }); + goodContext.globalThis = goodContext; + goodContext.WorkerGlobalScope = WorkerGlobalScope; + goodContext.ServiceWorkerGlobalScope = ServiceWorkerGlobalScope; + goodContext.location = new URL('https://puter.work'); + goodContext.addEventListener = () => {}; + goodContext.atob = globalThis.atob?.bind(globalThis); + goodContext.btoa = globalThis.btoa?.bind(globalThis); + // @ts-ignore + with (goodContext) { + #include "../../puter-js/dist/puter.js" + } + goodContext.puter.setAPIOrigin(apiOrigin); + goodContext.puter.setAuthToken(auth); + return goodContext.puter; + } + + #include "../../puter-js/dist/puter.js" + + puter.setAPIOrigin(apiOrigin); + puter.setAuthToken(auth); +}; + diff --git a/src/worker/template/puter-portable.template b/src/worker/template/puter-portable.template index c41831771..7e105b45a 100644 --- a/src/worker/template/puter-portable.template +++ b/src/worker/template/puter-portable.template @@ -1,49 +1,6 @@ -// This file is not actually in the webpack project, it is handled separately. +// The ordinary worker runtime: puter.js plus the `router` that user code +// registers routes on. Handled separately from the webpack project. -if (globalThis.Cloudflare) { - // Cloudflare Workers has a faulty EventTarget implementation which doesn't - // bind "this" to the event handler. - // https://github.com/cloudflare/workerd/issues/4453 - const CfEventTarget = EventTarget; - globalThis.EventTarget = class EventTarget extends CfEventTarget { - constructor(...args) { - super(...args); - } - - addEventListener(type, listener, options) { - super.addEventListener(type, listener.bind(this), options); - } - }; -} - -globalThis.init_puter_portable = (auth, apiOrigin, type) => { - if (type === 'userPuter') { - const goodContext = {}; - Object.getOwnPropertyNames(globalThis).forEach((name) => { - try { - goodContext[name] = globalThis[name]; - } catch {} - }); - goodContext.globalThis = goodContext; - goodContext.WorkerGlobalScope = WorkerGlobalScope; - goodContext.ServiceWorkerGlobalScope = ServiceWorkerGlobalScope; - goodContext.location = new URL('https://puter.work'); - goodContext.addEventListener = () => {}; - goodContext.atob = globalThis.atob?.bind(globalThis); - goodContext.btoa = globalThis.btoa?.bind(globalThis); - // @ts-ignore - with (goodContext) { - #include "../../puter-js/dist/puter.js" - } - goodContext.puter.setAPIOrigin(apiOrigin); - goodContext.puter.setAuthToken(auth); - return goodContext.puter; - } - - #include "../../puter-js/dist/puter.js" - - puter.setAPIOrigin(apiOrigin); - puter.setAuthToken(auth); -}; +#include "./puter-portable-core.template" #include "../dist/webpackPreamplePart.js"