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"