diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a073b1..361e97c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,4 @@ jobs: - run: npm ci - run: npm run build - run: npm test + - run: npm run check:browser diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6d4b806 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +## Unreleased + +### Added + +- `@orch8.io/sdk/serverless`: serverless executors. `drainOnce({ client, + handlers, deadlineMs, maxTasks })` claims a bounded batch through the worker + lease API, runs it concurrently with heartbeats, and releases every lease + still held at `deadlineMs - releaseMarginMs` (`started: true`; claims whose + `timeout_ms` cannot fit are released `started: false`). + `createLambdaExecutor` budgets from `context.getRemainingTimeInMillis()` + minus `safetyMarginMs`; `createCloudflareExecutor` exposes `scheduled` and + a secret-guarded `fetch` trigger and uses fetch only. The entry has no Node + built-ins and re-exports `Orch8Client`. +- `docs/browser-executor.md`: guide to running steps in the user's browser tab. + +- `Orch8Worker` `capabilities` option: advertises `RuntimeCapabilities` (kind, + trust, hardware, regions, plugins, ...) with every poll, bound to the worker + id with a fresh five-minute window, so the worker can claim tasks that carry + `$runtime` placement requirements. +- Worker handlers receive a second argument, `WorkerTaskContext` + (`effectId`, `leaseSecs`, `continuityEpoch`, `workerId`); also exported as + `workerTaskContext(task, workerId)`. The push task runner passes it too. +- `Orch8Worker` releases tasks it claimed but will not start (batch arrived + after `stop()`, or beyond free capacity) with `started: false`; previously + over-capacity claims were dropped until their lease expired. Heartbeats run + at half of the shortest in-flight task `lease_secs`. +- `@orch8.io/sdk/browser`: a browser-safe entry point (no Node built-ins in its + module graph; exported with a `browser` condition). CI bundles it with esbuild + for `platform: "browser"` and fails on any `node:` reference. +- `BrowserWorker`: runs step handlers in a web page with a short-lived, + browser-scoped session token. Polls with `kind: "browser"` capabilities, + heartbeats per `lease_secs`, completes/fails with `claim_epoch`, releases + in-flight tasks with keepalive fetch on `pagehide` / hidden tabs, refreshes + the token before expiry and stops when it lapses, and backs off exponentially. + The lease loop runs in a dedicated Web Worker (main-thread fallback) while + handlers run on the main thread; `ctx` exposes `effectId`, an `AbortSignal` + and `heartbeat()`. +- Page-data helpers: `readForm`, `readSelection`, `querySelectorText`, + `querySelectorAllText`, and a 1 MiB output guard (`assertOutputSize`, + `OutputTooLargeError`). +- `Orch8Client.createBrowserSession()` for the customer backend + (`POST /runtimes/browser-sessions`) and `Orch8Client.releaseTask()` + (`POST /workers/tasks/{id}/release`). +- Builder `runtime` step option (emitted as `params.$runtime`) and + `withPlacement()`; `RuntimePlacement`, `RuntimeCapabilities`, `RuntimeKind` + types; optional `effect_id`, `continuity_epoch`, `lease_secs`, + `target_runtime_id` and `runtime_kinds` on `WorkerTask`. + +### Changed + +- `Orch8Client` no longer imports `node:crypto`; request ids come from the Web + Crypto global (Node 18 falls back to `node:crypto` lazily), so the client + runs on Cloudflare Workers without `nodejs_compat`. +- `Orch8Worker` timer fields use `ReturnType` instead of + `NodeJS.Timeout`. + +### Fixed + +- `BrowserWorker` no longer runs a handler whose task was released (tab + hidden/closed, `stop()`) before the handler was invoked, and reports such a + release with `started: false`, so the engine returns the task to `pending` + immediately instead of treating it as a possibly-started effect. +- `BrowserWorker` turns a `413` from `complete` (output over the engine's + `ORCH8_BROWSER_OUTPUT_MAX_BYTES`) into a non-retryable `fail` instead of + holding the claim until the lease expires. +- `npm run test:e2e:browser`: end-to-end suite for `BrowserWorker` against a + real engine (`ORCH8_E2E_URL`, `ORCH8_E2E_ADMIN_KEY`). diff --git a/README.md b/README.md index 192d2e0..5079454 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,122 @@ A lease does not authorize offline execution. Handlers are not forcibly cancelle on lease loss or timeout; use bounded work and provider idempotency keys for external effects. `stop()` waits up to 30 seconds for executing handlers. +Handlers receive a second argument with the task's lease facts. `effectId` is +the server's deterministic idempotency key for the step's effect; pass it to +downstream APIs. All three are `null` against servers that predate them: + +```typescript +handlers: { + "charge-card": async (task, { effectId, leaseSecs, continuityEpoch }) => + payments.charge(task.params, { idempotencyKey: effectId ?? task.id }), +}, +``` + +Steps can require a runtime with `params.$runtime` (kinds, a specific +`runtime_id`, hardware, regions, plugins, trust). Such tasks are only handed +to workers that advertise matching capabilities, so pass `capabilities` to +claim them. The worker sends a fresh advertisement with every poll, bound to +its `workerId` and valid for at most five minutes: + +```typescript +new Orch8Worker({ + client, + workerId: "gpu-box-1", + handlers, + capabilities: { kind: "desktop", hardware: ["cuda"], regions: ["norway"] }, +}); +``` + +`kind` defaults to `server` and `trust` to `registered`; `handlers` defaults to +the configured handler names. Tasks claimed while `stop()` is in progress, or +beyond free capacity, are released at once (`POST /workers/tasks/{id}/release` +with `started: false`) instead of waiting for lease expiry; servers without +that endpoint fall back to lease expiry. + +## Browser worker (`@orch8.io/sdk/browser`) + +Run step handlers inside a web page — steps that need a human, the DOM, or data +the page already has. The browser entry has no Node built-ins (CI bundles it +with esbuild for `platform: "browser"` and fails on any `node:` import). + +**The browser never holds an API key or receives secrets.** Your backend mints +a short-lived token scoped to `kind=browser`, one runtime id and a handler +allowlist; it only works on the worker task endpoints. The engine never +dispatches credential-bearing steps to browser runtimes and filters the +instance context it sends them. Handlers *may* return page data (DOM, form +values, user input, the page's own fetched resources) as step output; output +is capped at 1 MiB of JSON and treated as untrusted input downstream. + +Backend (Node, operator/admin key): + +```typescript +import { Orch8Client } from "@orch8.io/sdk"; + +const orch8 = new Orch8Client({ baseUrl, headers: { "x-api-key": process.env.ORCH8_API_KEY! } }); + +app.post("/api/orch8/browser-session", requireLogin, async (req, res) => { + res.json(await orch8.createBrowserSession({ + runtimeId: `browser-${req.user.id}`, // stable id = per-user mailbox for targeted steps + handlers: ["confirm_shipping"], + ttlSecs: 900, // server default 900, max 3600 + })); +}); +``` + +Page: + +```typescript +import { BrowserWorker, readForm } from "@orch8.io/sdk/browser"; + +const worker = new BrowserWorker({ + baseUrl: "https://orch8.example.com", // add your origin to ORCH8_CORS_ORIGINS + getToken: () => fetch("/api/orch8/browser-session", { method: "POST" }).then((r) => r.json()), +}); + +worker.register("confirm_shipping", async (input, ctx) => { + const values = await askUser(input, { signal: ctx.signal }); // abort on lease loss / tab hidden + return { shipping: values, idempotencyKey: ctx.effectId }; +}); + +await worker.start(); +``` + +Place the step on a browser in the workflow: + +```typescript +workflow("fulfilment") + .step("confirm", "confirm_shipping", { orderId: "o-1" }, { + runtime: { runtime_kinds: ["browser"], requires_human_ui: true }, // or runtime_id: "browser-u1" + }); +``` + +How it behaves: + +- **Lease protocol.** Polls each handler with `capabilities { kind: "browser", runtime_id, handlers, expires_at }` + (≤ 5 minutes, never past the token expiry), heartbeats every `lease_secs / 3` (default lease 30 s), + then completes or fails echoing `claim_epoch`. A 404/409 heartbeat aborts `ctx.signal` with + `LeaseLostError` and the result is never acknowledged. `ctx.heartbeat()` extends the lease on demand. +- **Page lifecycle.** On `pagehide` and `visibilitychange → hidden` (configure with `releaseOn`) every + in-flight task is released via `POST /workers/tasks/{id}/release` using `fetch(..., { keepalive: true })` + with `started: true`, handlers see `TaskReleasedError` on `ctx.signal`, and polling pauses until the + page is visible again (or restored from the back/forward cache). Claims the page never started are + released with `started: false`. Long interactive steps may prefer `releaseOn: ["pagehide"]`. +- **Tokens.** `getToken` runs at start and `refreshMarginSecs` (60) before expiry, and again on a 401/403. + If the token lapses without a successful refresh, the worker stops and emits `{ type: "stopped", reason: "token_expired" }`. +- **Background tabs.** The poll/heartbeat loop runs in a dedicated Web Worker (started from a Blob URL; + worker timers are throttled far less than a hidden tab's), while handlers run on the main thread with DOM + access, bridged by `postMessage`. Without Worker support, or under a CSP without `worker-src blob:`, it falls + back to the main thread (`mode: "main"` forces that; `mode: "worker"` refuses to fall back). +- **Resilience and privacy.** Poll failures back off exponentially with jitter (`pollIntervalMs` 1 s up to + `maxBackoffMs` 30 s). `onEvent` receives lifecycle events with ids only; task input and output are never logged. +- **Page-data helpers.** `readForm(selectorOrForm)` (never reads password or file inputs, and hidden inputs only + with `includeHidden`), `readSelection()`, `querySelectorText()`, `querySelectorAllText()`, and + `assertOutputSize()` / `OutputTooLargeError` for the 1 MiB output guard (`maxOutputBytes` to change it). + +A complete example (HTML page, handler and backend) lives in +[`examples/browser-worker/`](examples/browser-worker/); the end-to-end guide to +running steps in a user's tab is [`docs/browser-executor.md`](docs/browser-executor.md). + ## Typed step IO (`orch8-typegen`) `orch8-typegen` turns a sequence file's JSON Schema contracts into TypeScript: @@ -305,6 +421,72 @@ export default createCloudflarePushHandler((env: Env) => ({ receivers. Signature checks need the raw body bytes; do not re-serialize parsed JSON. Keep handlers well inside your platform's function timeout. +## Serverless executors (`@orch8.io/sdk/serverless`) + +Push dispatch wakes a function per task. Serverless executors go the other +way: a scheduled or on-demand invocation **pulls** a bounded batch of tasks, +runs them inside the time it has left, and leaves no lease behind. They use +the regular worker lease API (`poll` → `heartbeat` → `complete` | `fail` | +`release`, echoing `claim_epoch`); no extra engine endpoints are involved. + +```typescript +// AWS Lambda, triggered by an EventBridge schedule (the event is ignored) +import { Orch8Client, createLambdaExecutor } from "@orch8.io/sdk/serverless"; + +export const handler = createLambdaExecutor(() => ({ + client: new Orch8Client({ baseUrl: process.env.ORCH8_URL!, headers: { "x-api-key": process.env.ORCH8_API_KEY! } }), + handlers: { "thumbnail.render": async (task, ctx) => render(task.params, { signal: ctx.signal }) }, + maxTasks: 5, // N tasks claimed per invocation + safetyMarginMs: 3000, // kept free before the Lambda timeout +})); +``` + +```typescript +// Cloudflare Workers: Cron Trigger + authenticated POST, fetch only (no nodejs_compat) +import { Orch8Client, createCloudflareExecutor } from "@orch8.io/sdk/serverless"; + +export default createCloudflareExecutor((env: Env) => ({ + client: new Orch8Client({ baseUrl: env.ORCH8_URL, headers: { "x-api-key": env.ORCH8_API_KEY } }), + handlers: { "lead.enrich": async (task) => enrich(task.params) }, + budgetMs: 25_000, // Workers have no remaining-time API + triggerSecret: env.ORCH8_TRIGGER_SECRET, // fetch trigger answers 404 without it +})); +``` + +Both adapters call `drainOnce({ client, handlers, deadlineMs, ... })`, which +you can use directly from any short-lived process (CronJob, CI, Vercel cron). +`deadlineMs` is an absolute epoch-ms deadline by which every lease must be +settled. Each drain: + +1. polls every handler (or `queueName`) with `limit` up to `maxTasks` + (default 10) and optional `capabilities` (Lambda advertises `kind: server`, + Cloudflare `kind: edge` by default), in rounds until the queue is empty, + `maxTasks` is reached, or less than `minTaskBudgetMs` remains; +2. runs claimed tasks concurrently, heartbeating at the shorter of + `heartbeatIntervalMs` and half the lease; +3. completes or fails each one like `Orch8Worker` (thrown errors are retryable + unless `err.retryable === false`; `timeout_ms` is enforced); +4. at `deadlineMs - releaseMarginMs` (default 1 s before), aborts `ctx.signal` + and releases every still-running task with `started: true`, so the engine + re-dispatches it (and marks a side-effecting step's receipt Unknown) instead + of waiting for the lease to expire. A claim whose `timeout_ms` cannot fit + the remaining budget is released with `started: false` without running. + A result that arrives after its release is never acknowledged. + +Handlers are plain `Orch8Worker` handlers; the context additionally has +`signal`, `deadlineMs`, `remainingMs()` and `heartbeat()`. The returned +`DrainResult` lists every task's outcome (`completed`, `failed`, `released`, +`released_unstarted`, `lease_lost`, `ack_rejected`) and why the drain stopped +(`empty`, `deadline`, `max_tasks`, `poll_error`); the Cloudflare fetch trigger +returns it as JSON (502 on `poll_error`). + +The `@orch8.io/sdk/serverless` module graph has no Node built-ins and +re-exports `Orch8Client`; import from it (not the root entry) on edge runtimes. +Handlers still must be idempotent: a released task runs again elsewhere. Full +examples: [`examples/serverless-lambda/`](examples/serverless-lambda/) (with a +SAM template), [`examples/serverless-cloudflare/`](examples/serverless-cloudflare/) +(with `wrangler.toml`) and [`examples/serverless-drain/`](examples/serverless-drain/). + ## Framework integrations Each integration is a separate subpath with its framework as an optional peer @@ -510,4 +692,7 @@ try { npm install npm run build npm test +npm run typecheck +npm run check:browser # browser bundle has no node: imports; lease worker source is fresh +npm run generate:lease-worker # after editing src/browser/engine.ts ``` diff --git a/docs/browser-executor.md b/docs/browser-executor.md new file mode 100644 index 0000000..0935819 --- /dev/null +++ b/docs/browser-executor.md @@ -0,0 +1,62 @@ +# Browser executor: run steps in the user's tab + +Some steps belong in the browser: a human confirms or edits data, the step +reads the page the user is on, or it uses data the page already fetched. The +browser executor (`BrowserWorker` from `@orch8.io/sdk/browser`) turns an open +tab into a short-lived Orch8 runtime that claims those steps, and only those. + +This guide is the end-to-end path. The API reference is the +[Browser worker section of the README](../README.md#browser-worker-orch8iosdkbrowser); +a runnable example is in [`examples/browser-worker/`](../examples/browser-worker/). + +## The three pieces + +| Where | What | Code | +|---|---|---| +| Your backend | Mints a browser session token with its operator key. The key never reaches the page. | `client.createBrowserSession({ runtimeId, handlers, ttlSecs })` | +| Your page | Registers handlers, then polls for its steps with that token. | `new BrowserWorker({ baseUrl, getToken }).register(...).start()` | +| Your workflow | Places the step on a browser runtime. | step option `runtime: { runtime_kinds: ["browser"] }` or `runtime_id: "browser-"` | + +Also add the page's origin to the engine's `ORCH8_CORS_ORIGINS`. + +## Targeting: any tab or this user's tab + +- `runtime: { runtime_kinds: ["browser"], requires_human_ui: true }` — any + connected browser session whose handler allowlist includes the handler. +- `runtime: { runtime_id: "browser-u1" }` — only the session minted with + `runtimeId: "browser-u1"`. A stable per-user id makes the step wait in that + user's "mailbox" until they open the app, even across reloads and devices. + +## Lifecycle guarantees + +| Situation | What happens | +|---|---| +| Tab open and visible | Polls every handler with `capabilities { kind: "browser", runtime_id, handlers }`, heartbeats every `lease_secs / 3`, completes or fails echoing `claim_epoch`. | +| Tab hidden (`visibilitychange`) or closed/navigated (`pagehide`) | In-flight tasks are released at once with a `keepalive` fetch (`started: true`), `ctx.signal` aborts with `TaskReleasedError`, and polling pauses until the page is visible again. Use `releaseOn: ["pagehide"]` to keep long interactive steps while the tab is in the background. | +| Lease lost (heartbeat 404/409) | `ctx.signal` aborts with `LeaseLostError`; the result is never acknowledged. | +| Token about to expire | `getToken` is called again `refreshMarginSecs` (60 s) before expiry and on any 401/403; if it lapses, the worker stops with `reason: "token_expired"`. | +| Background throttling | The lease loop runs in a dedicated Web Worker; without Worker support (or under a strict CSP) it falls back to the main thread. | + +Released steps return to `pending` and are dispatched again (to another tab +of the same runtime id, or any browser runtime), so make handlers idempotent +and use `ctx.effectId` as the idempotency key for any external call. + +## Security model + +- The page holds only a browser session token: scoped to `kind=browser`, one + runtime id and a handler allowlist, valid on the worker task endpoints only, + 900 s by default (3600 s max). +- The engine never dispatches credential-bearing steps to browser runtimes and + filters the instance context it sends them. +- Output is page data (DOM, form values, user input) capped at 1 MiB of JSON; + treat it as untrusted input in later steps. `readForm` never reads password + or file inputs. + +## Checklist + +1. Backend route that authenticates the user and returns + `createBrowserSession({ runtimeId: \`browser-${user.id}\`, handlers: [...] })`. +2. `ORCH8_CORS_ORIGINS` includes the page origin. +3. Page registers every handler named in the session allowlist before `start()`. +4. Workflow step has a `runtime` placement and a `timeout` long enough for a human. +5. Handlers honour `ctx.signal` and return idempotent results. diff --git a/examples/browser-worker/index.html b/examples/browser-worker/index.html new file mode 100644 index 0000000..a4e19d7 --- /dev/null +++ b/examples/browser-worker/index.html @@ -0,0 +1,26 @@ + + + + + + Orch8 browser worker example + + + +

Order #42

+

Waiting for work…

+ + + + + + diff --git a/examples/browser-worker/page.ts b/examples/browser-worker/page.ts new file mode 100644 index 0000000..d357f5b --- /dev/null +++ b/examples/browser-worker/page.ts @@ -0,0 +1,54 @@ +// Browser side: runs `confirm_shipping` steps that need a human and this page. +// The page never holds an API key or any secret: it asks YOUR backend for a +// short-lived, browser-scoped session token. +import { BrowserWorker, querySelectorText, readForm } from "@orch8.io/sdk/browser"; + +const statusEl = document.getElementById("status")!; +const form = document.getElementById("shipping") as HTMLFormElement; + +const worker = new BrowserWorker({ + baseUrl: "https://orch8.example.com", // must list this origin in ORCH8_CORS_ORIGINS + getToken: async () => { + // Authenticated by your app's own session cookie. + const res = await fetch("/api/orch8/browser-session", { method: "POST", credentials: "include" }); + if (!res.ok) throw new Error(`session endpoint returned ${res.status}`); + return res.json(); // { token, runtimeId, expiresAt, handlers } + }, + onEvent: (event) => { + // Events carry ids only, never step input or output. + if (event.type === "task_started") statusEl.textContent = "Please confirm shipping details."; + if (event.type === "released") statusEl.textContent = "Paused while the tab is hidden."; + if (event.type === "stopped") statusEl.textContent = `Stopped (${event.reason}).`; + }, +}); + +worker.register<{ orderId: string }, Record>("confirm_shipping", (input, ctx) => + new Promise((resolve, reject) => { + form.hidden = false; + const onSubmit = (event: SubmitEvent) => { + event.preventDefault(); + cleanup(); + // Page data (user input + DOM) is returned as step output. + resolve({ + orderId: input.orderId, + orderTitle: querySelectorText(".order-title"), + shipping: readForm(form), + idempotencyKey: ctx.effectId, + }); + }; + // Lease lost, tab hidden/closed, or step timeout: stop waiting for the user. + const onAbort = () => { + cleanup(); + reject(ctx.signal.reason); + }; + const cleanup = () => { + form.hidden = true; + form.removeEventListener("submit", onSubmit); + ctx.signal.removeEventListener("abort", onAbort); + }; + form.addEventListener("submit", onSubmit); + ctx.signal.addEventListener("abort", onAbort); + }), +); + +void worker.start(); diff --git a/examples/browser-worker/server.ts b/examples/browser-worker/server.ts new file mode 100644 index 0000000..9e6f372 --- /dev/null +++ b/examples/browser-worker/server.ts @@ -0,0 +1,40 @@ +// Backend side (Node): mints browser sessions and defines a workflow whose +// `confirm_shipping` step is placed on the browser. The API key stays here. +import express from "express"; +import { Orch8Client, workflow } from "@orch8.io/sdk"; + +const orch8 = new Orch8Client({ + baseUrl: process.env.ORCH8_ENGINE_URL ?? "http://localhost:8080", + tenantId: process.env.ORCH8_TENANT_ID, + headers: { "x-api-key": process.env.ORCH8_API_KEY ?? "" }, // operator/admin key +}); + +const app = express(); + +// Replace with your real authentication; tie the runtime id to the signed-in user. +function currentUserId(req: express.Request): string | null { + return (req.headers["x-demo-user"] as string | undefined) ?? null; +} + +app.post("/api/orch8/browser-session", async (req, res) => { + const userId = currentUserId(req); + if (!userId) return res.status(401).end(); + const session = await orch8.createBrowserSession({ + runtimeId: `browser-${userId}`, // stable per user: targeted steps wait in this "mailbox" + handlers: ["confirm_shipping"], + ttlSecs: 900, + }); + res.json(session); // { token, runtimeId, expiresAt, handlers } +}); + +// Workflow: the browser step receives no credentials (the engine refuses to +// dispatch credential-bearing steps to browser runtimes). +export const fulfilment = workflow("fulfilment") + .step("confirm", "confirm_shipping", { orderId: "{{context.data.order_id}}" }, { + runtime: { runtime_kinds: ["browser"], requires_human_ui: true }, + timeout: 15 * 60_000, + }) + .step("ship", "create_shipment", { shipping: "{{outputs.confirm.shipping}}" }) + .build(); + +app.listen(3000, () => console.log("listening on http://localhost:3000")); diff --git a/examples/serverless-cloudflare/worker.ts b/examples/serverless-cloudflare/worker.ts new file mode 100644 index 0000000..3d5332f --- /dev/null +++ b/examples/serverless-cloudflare/worker.ts @@ -0,0 +1,34 @@ +// Cloudflare Workers executor: a Cron Trigger drains every minute, and a +// POST with `Authorization: Bearer $ORCH8_TRIGGER_SECRET` drains on demand +// (e.g. from a push-mode queue or a webhook). Uses fetch only — no Node APIs, +// no `nodejs_compat` flag. +import { Orch8Client, createCloudflareExecutor } from "@orch8.io/sdk/serverless"; + +interface Env { + ORCH8_URL: string; + ORCH8_TENANT_ID: string; + ORCH8_API_KEY: string; // `wrangler secret put ORCH8_API_KEY` + ORCH8_TRIGGER_SECRET: string; // `wrangler secret put ORCH8_TRIGGER_SECRET` +} + +export default createCloudflareExecutor((env: Env) => ({ + client: new Orch8Client({ + baseUrl: env.ORCH8_URL, + tenantId: env.ORCH8_TENANT_ID, + headers: { "x-api-key": env.ORCH8_API_KEY }, + }), + handlers: { + "lead.enrich": async (task, ctx) => { + const { domain } = task.params as { domain: string }; + const res = await fetch(`https://api.example.com/companies/${encodeURIComponent(domain)}`, { + signal: ctx.signal, + headers: { "Idempotency-Key": ctx.effectId ?? task.id }, + }); + return { company: await res.json() }; + }, + }, + maxTasks: 10, + budgetMs: 25_000, // keep below your plan's limit; Workers expose no remaining-time API + triggerSecret: env.ORCH8_TRIGGER_SECRET, + capabilities: { regions: ["global"] }, // advertised as kind "edge" +})); diff --git a/examples/serverless-cloudflare/wrangler.toml b/examples/serverless-cloudflare/wrangler.toml new file mode 100644 index 0000000..4a0d340 --- /dev/null +++ b/examples/serverless-cloudflare/wrangler.toml @@ -0,0 +1,11 @@ +name = "orch8-executor" +main = "worker.ts" +compatibility_date = "2026-09-01" +# No nodejs_compat flag needed: @orch8.io/sdk/serverless uses fetch only. + +[vars] +ORCH8_URL = "https://orch8.example.com" +ORCH8_TENANT_ID = "acme" + +[triggers] +crons = ["* * * * *"] diff --git a/examples/serverless-drain/drain.ts b/examples/serverless-drain/drain.ts new file mode 100644 index 0000000..b3cab36 --- /dev/null +++ b/examples/serverless-drain/drain.ts @@ -0,0 +1,16 @@ +// drainOnce in any short-lived process (Kubernetes CronJob, GitHub Actions, +// Vercel cron): claim up to 20 tasks, finish or release everything within 50 s. +import { Orch8Client, drainOnce } from "@orch8.io/sdk/serverless"; + +const result = await drainOnce({ + client: new Orch8Client({ + baseUrl: process.env.ORCH8_URL!, + tenantId: process.env.ORCH8_TENANT_ID, + headers: { "x-api-key": process.env.ORCH8_API_KEY! }, + }), + handlers: { "report.build": async (task) => ({ built: task.params }) }, + maxTasks: 20, + deadlineMs: Date.now() + 50_000, +}); +console.log(JSON.stringify({ claimed: result.claimed, completed: result.completed, released: result.released, stoppedBy: result.stoppedBy })); +process.exitCode = result.stoppedBy === "poll_error" ? 1 : 0; diff --git a/examples/serverless-lambda/handler.ts b/examples/serverless-lambda/handler.ts new file mode 100644 index 0000000..5d8bbac --- /dev/null +++ b/examples/serverless-lambda/handler.ts @@ -0,0 +1,26 @@ +// AWS Lambda executor: an EventBridge schedule (e.g. rate(1 minute)) invokes +// this function; each invocation claims up to 5 tasks, runs them inside the +// remaining invocation time minus a safety margin, heartbeats, and releases +// anything still running before Lambda's timeout. +import { Orch8Client, createLambdaExecutor } from "@orch8.io/sdk/serverless"; + +export const handler = createLambdaExecutor(() => ({ + client: new Orch8Client({ + baseUrl: process.env.ORCH8_URL!, + tenantId: process.env.ORCH8_TENANT_ID, + headers: { "x-api-key": process.env.ORCH8_API_KEY! }, // from Secrets Manager / SSM in production + }), + handlers: { + "thumbnail.render": async (task, ctx) => { + const { url } = task.params as { url: string }; + // Pass ctx.signal to I/O so work stops when the budget ends (the task is + // then released and re-dispatched) and ctx.effectId as an idempotency key. + const res = await fetch(url, { signal: ctx.signal }); + return { bytes: (await res.arrayBuffer()).byteLength, idempotencyKey: ctx.effectId }; + }, + }, + maxTasks: 5, + safetyMarginMs: 3_000, // kept free before the Lambda timeout + releaseMarginMs: 1_000, // release calls happen inside this window + capabilities: { kind: "server", regions: [process.env.AWS_REGION ?? "us-east-1"] }, +})); diff --git a/examples/serverless-lambda/template.yaml b/examples/serverless-lambda/template.yaml new file mode 100644 index 0000000..347988d --- /dev/null +++ b/examples/serverless-lambda/template.yaml @@ -0,0 +1,34 @@ +# AWS SAM template: a scheduled Orch8 executor. Deploy with `sam build && sam deploy --guided`. +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Parameters: + Orch8Url: + Type: String + Orch8TenantId: + Type: String + Orch8ApiKey: + Type: String + NoEcho: true +Resources: + Orch8Executor: + Type: AWS::Serverless::Function + Properties: + Runtime: nodejs20.x + Handler: handler.handler + Timeout: 60 # the executor budgets itself from context.getRemainingTimeInMillis() + MemorySize: 512 + ReservedConcurrentExecutions: 4 + Environment: + Variables: + ORCH8_URL: !Ref Orch8Url + ORCH8_TENANT_ID: !Ref Orch8TenantId + ORCH8_API_KEY: !Ref Orch8ApiKey + Events: + Drain: + Type: ScheduleV2 + Properties: + ScheduleExpression: rate(1 minute) + Metadata: + BuildMethod: esbuild + BuildProperties: + EntryPoints: [handler.ts] diff --git a/package-lock.json b/package-lock.json index 3dca6ab..11989dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "@nestjs/core": "^10.4.22", "@types/express": "^4.17.25", "@types/node": "^20.0.0", + "esbuild": "^0.25.12", "express": "^4.22.3", + "jsdom": "^25.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "typescript": "^5.4.0", @@ -29,14 +31,48 @@ "node": ">=18" }, "peerDependencies": { - "@orch8/engine-native": ">=0.7.1" + "@nestjs/common": ">=10", + "@nestjs/core": ">=10", + "@openai/agents": ">=0.1", + "@orch8/engine-native": ">=0.7.1", + "ai": ">=4", + "express": ">=4" }, "peerDependenciesMeta": { + "@nestjs/common": { + "optional": true + }, + "@nestjs/core": { + "optional": true + }, + "@openai/agents": { + "optional": true + }, "@orch8/engine-native": { "optional": true + }, + "ai": { + "optional": true + }, + "express": { + "optional": true } } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@borewit/text-codec": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", @@ -48,6 +84,121 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1244,6 +1395,16 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1277,6 +1438,13 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", @@ -1434,6 +1602,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -1481,6 +1662,78 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1499,6 +1752,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1509,6 +1769,16 @@ "node": ">=6" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1562,6 +1832,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1602,6 +1885,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1839,6 +2138,23 @@ "dev": true, "license": "MIT" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1959,6 +2275,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1972,6 +2304,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1993,6 +2338,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -2044,6 +2417,13 @@ "node": ">= 0.10" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/iterare": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", @@ -2061,6 +2441,84 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2068,6 +2526,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2211,6 +2676,13 @@ } } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2237,6 +2709,19 @@ "node": ">= 0.8" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2338,6 +2823,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -2433,6 +2928,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -2471,6 +2973,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -2696,6 +3211,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2757,6 +3279,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2786,6 +3328,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -3062,6 +3617,19 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -3069,6 +3637,43 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -3097,6 +3702,45 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 186a808..652b5a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@orch8.io/sdk", "version": "0.7.0", - "description": "Node.js SDK for Orch8 workflow engine", + "description": "Node.js and browser SDK for Orch8 workflow engine", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { @@ -22,6 +22,13 @@ "default": "./dist/index.js" } }, + "./browser": { + "types": "./dist/browser/index.d.ts", + "browser": "./dist/browser/index.js", + "import": "./dist/browser/index.js", + "require": "./dist/browser/index.js", + "default": "./dist/browser/index.js" + }, "./push": { "import": { "types": "./dist/push/index.d.ts", @@ -32,6 +39,16 @@ "default": "./dist/push/index.js" } }, + "./serverless": { + "import": { + "types": "./dist/serverless/index.d.ts", + "default": "./dist/serverless/index.js" + }, + "require": { + "types": "./dist/serverless/index.d.ts", + "default": "./dist/serverless/index.js" + } + }, "./testing": { "import": { "types": "./dist/testing/index.d.ts", @@ -102,14 +119,20 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e:browser": "vitest run test/e2e/browser-worker.e2e.test.ts", + "check:browser": "node scripts/generate-lease-worker.mjs --check && node scripts/check-browser-bundle.mjs", + "generate:lease-worker": "node scripts/generate-lease-worker.mjs" }, "keywords": [ "orch8", "workflow", "orchestration", - "sdk" + "sdk", + "browser", + "serverless" ], "repository": { "type": "git", @@ -156,7 +179,9 @@ "@nestjs/core": "^10.4.22", "@types/express": "^4.17.25", "@types/node": "^20.0.0", + "esbuild": "^0.25.12", "express": "^4.22.3", + "jsdom": "^25.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "typescript": "^5.4.0", diff --git a/scripts/check-browser-bundle.mjs b/scripts/check-browser-bundle.mjs new file mode 100644 index 0000000..15d9a2b --- /dev/null +++ b/scripts/check-browser-bundle.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// Proves `@orch8.io/sdk/browser` is browser-safe: +// 1. bundles the entry (source and, when built, the published dist) with +// esbuild for `platform: "browser"` with no Node polyfills — an import +// of a Node built-in fails to resolve; +// 2. rejects any `node:` specifier or `require(` left in the output; +// 3. evaluates the pre-bundled lease-worker source, taken from a minified, +// keep-names, ES2017 consumer-style bundle, in isolation (as a Web +// Worker would), to prove it survives consumer-bundler transforms. +import { build } from "esbuild"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { runInNewContext } from "node:vm"; + +const root = resolve(import.meta.dirname ?? new URL(".", import.meta.url).pathname, ".."); +const entries = [["src", resolve(root, "src/browser/index.ts")]]; +const dist = resolve(root, "dist/browser/index.js"); +if (existsSync(dist)) entries.push(["dist", dist]); + +let failed = false; +const fail = (message) => { + failed = true; + console.error(`✗ ${message}`); +}; + +let bundleForSelfTest; +for (const [label, entry] of entries) { + let result; + try { + result = await build({ + entryPoints: [entry], + bundle: true, + platform: "browser", + format: "esm", + target: "es2017", + minify: true, + keepNames: true, + write: false, + logLevel: "silent", + }); + } catch (err) { + fail(`${label}: esbuild could not bundle for the browser:\n${err.message}`); + continue; + } + const code = result.outputFiles[0].text; + const nodeRefs = code.match(/["'`]node:[a-z_/]+["'`]/g); + if (nodeRefs) fail(`${label}: bundle references Node built-ins: ${[...new Set(nodeRefs)].join(", ")}`); + if (/\brequire\(/.test(code)) fail(`${label}: bundle contains require()`); + console.log(`✓ ${label}: browser bundle ${(code.length / 1024).toFixed(1)} KiB, no node: imports`); + if (label === "src") bundleForSelfTest = code; +} + +if (bundleForSelfTest) { + const mod = await import(`data:text/javascript;base64,${Buffer.from(bundleForSelfTest).toString("base64")}`); + const source = mod.__leaseWorkerSource; + const posted = []; + const requests = []; + let onMessage; + const self = { + postMessage: (message) => posted.push(message), + addEventListener: (type, listener) => { if (type === "message") onMessage = listener; }, + fetch: (url, init) => { + requests.push({ url, body: JSON.parse(init.body) }); + return Promise.resolve(new Response(JSON.stringify({ tasks: [], lease_secs: 30 }), { status: 200 })); + }, + }; + try { + // A fresh realm with only worker-like globals, so the source cannot lean + // on anything from this script or Node. + runInNewContext(source, { self, setTimeout, clearTimeout, AbortController }); + onMessage({ data: { type: "start", config: { + baseUrl: "https://engine.test", handlers: ["h"], maxConcurrent: 1, pollIntervalMs: 60_000, + maxBackoffMs: 60_000, capabilityTtlSecs: 240, trust: "registered", refreshMarginMs: 60_000, + defaultLeaseSecs: 30, requestTimeoutMs: 30_000, + } } }); + onMessage({ data: { type: "token", token: "t", expiresAt: Date.now() + 600_000, runtimeId: "tab-1" } }); + await new Promise((r) => setTimeout(r, 50)); + onMessage({ data: { type: "stop", reason: "self-test" } }); + if (posted[0]?.type !== "ready") fail("lease worker did not post ready"); + const poll = requests[0]; + if (!poll || !poll.url.endsWith("/workers/tasks/poll") || poll.body.capabilities?.kind !== "browser") { + fail("lease worker did not poll with browser capabilities"); + } else { + console.log("✓ lease worker source runs in isolation (from a minified, keep-names, es2017 bundle)"); + } + } catch (err) { + fail(`serialized lease worker failed in isolation: ${err.stack ?? err}`); + } +} + +process.exit(failed ? 1 : 0); diff --git a/scripts/generate-lease-worker.mjs b/scripts/generate-lease-worker.mjs new file mode 100644 index 0000000..076d317 --- /dev/null +++ b/scripts/generate-lease-worker.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Pre-bundles the dedicated lease worker into a string constant so that +// `BrowserWorker` can start it from a Blob URL without a separate file and +// without consumer bundlers transforming its code. +// node scripts/generate-lease-worker.mjs write the file +// node scripts/generate-lease-worker.mjs --check exit 1 when stale +import { build } from "esbuild"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const target = resolve(root, "src/browser/lease-worker-source.ts"); + +export async function renderLeaseWorkerModule() { + const result = await build({ + entryPoints: [resolve(root, "scripts/lease-worker-entry.ts")], + bundle: true, + platform: "browser", + format: "iife", + target: "es2017", + minify: true, + legalComments: "none", + write: false, + logLevel: "silent", + }); + const code = result.outputFiles[0].text.trim(); + return ( + "// GENERATED by scripts/generate-lease-worker.mjs from src/browser/engine.ts. Do not edit.\n" + + "// Regenerate with `npm run generate:lease-worker`.\n" + + `export const LEASE_WORKER_SOURCE = ${JSON.stringify(code)};\n` + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const rendered = await renderLeaseWorkerModule(); + if (process.argv.includes("--check")) { + let current = ""; + try { current = readFileSync(target, "utf8"); } catch {} + if (current !== rendered) { + console.error("src/browser/lease-worker-source.ts is stale; run `npm run generate:lease-worker`"); + process.exit(1); + } + console.log("✓ lease worker source is up to date"); + } else { + writeFileSync(target, rendered); + console.log(`wrote ${target} (${rendered.length} bytes)`); + } +} diff --git a/scripts/lease-worker-entry.ts b/scripts/lease-worker-entry.ts new file mode 100644 index 0000000..64641f9 --- /dev/null +++ b/scripts/lease-worker-entry.ts @@ -0,0 +1,4 @@ +// Entry bundled into src/browser/lease-worker-source.ts (see generate-lease-worker.mjs). +import { leaseEngine, leaseWorkerBootstrap } from "../src/browser/engine.js"; + +leaseWorkerBootstrap(self as unknown as Parameters[0], leaseEngine); diff --git a/src/__tests__/browser-bundle.test.ts b/src/__tests__/browser-bundle.test.ts new file mode 100644 index 0000000..4f52d5a --- /dev/null +++ b/src/__tests__/browser-bundle.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { LEASE_WORKER_SOURCE } from "../browser/lease-worker-source.js"; + +describe("browser entry", () => { + it("ships a lease worker source regenerated from engine.ts", async () => { + // @ts-expect-error -- plain ESM build script without type declarations + const { renderLeaseWorkerModule } = await import("../../scripts/generate-lease-worker.mjs"); + const committed = readFileSync(resolve(process.cwd(), "src/browser/lease-worker-source.ts"), "utf8"); + expect(await renderLeaseWorkerModule(), "run `npm run generate:lease-worker`").toBe(committed); + }); + + it("keeps Node built-ins out of the lease worker source", () => { + expect(LEASE_WORKER_SOURCE).not.toMatch(/node:|require\(/); + }); + + it("maps the ./browser export for browser, import and require conditions", () => { + const pkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")); + expect(pkg.exports["./browser"]).toMatchObject({ + types: "./dist/browser/index.d.ts", + browser: "./dist/browser/index.js", + import: "./dist/browser/index.js", + require: "./dist/browser/index.js", + }); + }); +}); diff --git a/src/__tests__/browser-lease-worker.test.ts b/src/__tests__/browser-lease-worker.test.ts new file mode 100644 index 0000000..45d1559 --- /dev/null +++ b/src/__tests__/browser-lease-worker.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { runInNewContext } from "node:vm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserWorker } from "../browser/index.js"; + +// A dedicated-worker stand-in: runs the Blob's source in a fresh VM realm +// that only has worker-like globals (no access to the test's scope), bridging +// postMessage asynchronously and structured-cloning every message. +function installFakeWorker(fetchImpl: typeof fetch, options: { failConstruct?: boolean } = {}) { + const blobs = new Map(); + const created: FakeWorker[] = []; + let seq = 0; + class FakeBlob { + constructor(readonly parts: string[]) {} + } + class FakeWorker { + private readonly toPage = new Map void>>(); + private toWorker: ((event: { data: unknown }) => void) | null = null; + terminated = false; + constructor(url: string) { + if (options.failConstruct) throw new DOMException("blocked by CSP", "SecurityError"); + created.push(this); + const source = blobs.get(url); + if (!source) throw new Error("unknown blob URL"); + const self = { + postMessage: (message: unknown) => { + const data = structuredClone(message); + setTimeout(() => { + if (!this.terminated) for (const l of this.toPage.get("message") ?? []) l({ data }); + }, 0); + }, + addEventListener: (type: string, listener: (event: { data: unknown }) => void) => { + if (type === "message") this.toWorker = listener; + }, + fetch: fetchImpl, + }; + runInNewContext(source, { self, setTimeout, clearTimeout, AbortController, Date }); + } + postMessage(message: unknown) { + const data = structuredClone(message); + setTimeout(() => { + if (!this.terminated) this.toWorker?.({ data }); + }, 0); + } + addEventListener(type: string, listener: (event: { data: unknown }) => void) { + this.toPage.set(type, [...(this.toPage.get(type) ?? []), listener]); + } + terminate() { + this.terminated = true; + } + } + vi.stubGlobal("Blob", FakeBlob); + vi.stubGlobal("Worker", FakeWorker); + // jsdom has no object URLs; define them for the duration of the test. + Object.assign(URL, { + createObjectURL: (blob: unknown) => { + const url = `blob:fake/${++seq}`; + blobs.set(url, (blob as FakeBlob).parts.join("")); + return url; + }, + revokeObjectURL: (url: string) => { + blobs.delete(url); + }, + }); + return { created }; +} + +function fakeFetch() { + const calls: Array<{ path: string; body: Record }> = []; + const pending = [ + { + id: "task-1", instance_id: "i", block_id: "b", handler_name: "confirm", queue_name: null, + params: { q: 1 }, context: {}, attempt: 1, timeout_ms: null, state: "claimed", worker_id: "tab-1", + claimed_at: null, heartbeat_at: null, completed_at: null, output: null, error_message: null, + error_retryable: null, created_at: "", claim_epoch: 3, checkpoint_seq: 0, effect_id: "eff-9", + }, + ]; + const impl = vi.fn(async (url: string, init?: RequestInit) => { + const path = String(url).replace("https://engine.test", ""); + calls.push({ path, body: init?.body ? JSON.parse(String(init.body)) : {} }); + const body = path.endsWith("/poll") ? { tasks: pending.splice(0), lease_secs: 30 } : undefined; + return { + status: body ? 200 : 204, + ok: true, + text: async () => (body ? JSON.stringify(body) : ""), + } as Response; + }); + return { calls, fetch: impl as unknown as typeof fetch }; +} + +async function flush(rounds = 30): Promise { + // 1 ms steps: postMessage hops are modelled as timers. + for (let i = 0; i < rounds; i++) await vi.advanceTimersByTimeAsync(1); +} + +const token = async () => ({ token: "tok", expiresAt: Date.now() + 600_000, runtimeId: "tab-1" }); + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete (URL as { createObjectURL?: unknown }).createObjectURL; + delete (URL as { revokeObjectURL?: unknown }).revokeObjectURL; +}); + +describe("BrowserWorker in a dedicated Web Worker", () => { + it("runs the lease loop in the worker and the handler on the page", async () => { + const server = fakeFetch(); + const { created } = installFakeWorker(server.fetch); + const seen: unknown[] = []; + // The page-side fetch is only used for keepalive releases. + const worker = new BrowserWorker({ baseUrl: "https://engine.test", getToken: token, fetch: server.fetch }); + worker.register("confirm", async (input, ctx) => { + seen.push(input, ctx.effectId, typeof document.querySelector); + return { ok: true }; + }); + const starting = worker.start(); + await flush(); + await starting; + expect(worker.mode).toBe("worker"); + expect(created).toHaveLength(1); + await flush(); + + expect(seen).toEqual([{ q: 1 }, "eff-9", "function"]); + const poll = server.calls.find((c) => c.path.endsWith("/poll"))!; + expect(poll.body.capabilities).toMatchObject({ kind: "browser", runtime_id: "tab-1" }); + const complete = server.calls.find((c) => c.path.endsWith("/complete"))!; + expect(complete.body).toEqual({ worker_id: "tab-1", claim_epoch: 3, output: { ok: true } }); + await worker.stop(); + expect(created[0].terminated).toBe(true); + }); + + it("falls back to the main thread when the worker is blocked", async () => { + const server = fakeFetch(); + installFakeWorker(server.fetch, { failConstruct: true }); + const worker = new BrowserWorker({ baseUrl: "https://engine.test", getToken: token, fetch: server.fetch }); + worker.register("confirm", async () => ({})); + await worker.start(); + expect(worker.mode).toBe("main"); + await flush(); + expect(server.calls.some((c) => c.path.endsWith("/complete"))).toBe(true); + await worker.stop(); + }); + + it("mode: 'worker' refuses to fall back", async () => { + installFakeWorker(fakeFetch().fetch, { failConstruct: true }); + const worker = new BrowserWorker({ baseUrl: "https://engine.test", getToken: token, mode: "worker" }); + worker.register("confirm", async () => ({})); + await expect(worker.start()).rejects.toThrow(/Web Workers are unavailable/); + }); +}); diff --git a/src/__tests__/browser-page-data.test.ts b/src/__tests__/browser-page-data.test.ts new file mode 100644 index 0000000..413523a --- /dev/null +++ b/src/__tests__/browser-page-data.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from "vitest"; +import { + MAX_OUTPUT_BYTES, + OutputTooLargeError, + assertOutputSize, + jsonByteLength, + querySelectorAllText, + querySelectorText, + readForm, + readSelection, +} from "../browser/index.js"; + +beforeEach(() => { + document.body.innerHTML = ` +
+ + + + + + + + + + + + + +
+

Order #42

+
  • one
  • two
  • `; +}); + +describe("page-data helpers", () => { + it("readForm returns JSON values and never reads passwords, files or (by default) hidden fields", () => { + expect(readForm("#order")).toEqual({ + email: "ada@example.com", + tags: ["a", "c"], + size: "m", + colors: ["red", "blue"], + note: " hello ", + }); + const withHidden = readForm(document.querySelector("#order")!, { includeHidden: true }); + expect(withHidden.csrf).toBe("csrf-token"); + expect(withHidden).not.toHaveProperty("password"); + }); + + it("readForm reports a missing form", () => { + expect(() => readForm("#missing")).toThrow(/no form matches #missing/); + }); + + it("querySelectorText and querySelectorAllText trim text content", () => { + expect(querySelectorText(".title")).toBe("Order #42"); + expect(querySelectorText(".absent")).toBeNull(); + expect(querySelectorAllText(".item")).toEqual(["one", "two"]); + }); + + it("readSelection returns the selected text", () => { + const range = document.createRange(); + range.selectNodeContents(document.querySelector(".title")!); + getSelection()!.removeAllRanges(); + getSelection()!.addRange(range); + expect(readSelection().trim()).toBe("Order #42"); + }); + + it("assertOutputSize measures UTF-8 JSON bytes against the 1 MiB default", () => { + expect(MAX_OUTPUT_BYTES).toBe(1_048_576); + expect(jsonByteLength({ a: "é" })).toBe(new TextEncoder().encode('{"a":"é"}').length); + expect(() => assertOutputSize({ s: "x".repeat(100) })).not.toThrow(); + expect(() => assertOutputSize("x".repeat(MAX_OUTPUT_BYTES))).toThrow(OutputTooLargeError); + try { + assertOutputSize({ s: "0123456789" }, 8); + } catch (err) { + expect(err).toBeInstanceOf(OutputTooLargeError); + expect((err as OutputTooLargeError).retryable).toBe(false); + expect((err as Error).message).toMatch(/exceeds the 8-byte limit/); + } + }); +}); diff --git a/src/__tests__/browser-worker.test.ts b/src/__tests__/browser-worker.test.ts new file mode 100644 index 0000000..70723e0 --- /dev/null +++ b/src/__tests__/browser-worker.test.ts @@ -0,0 +1,561 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BrowserWorker, + LeaseLostError, + TaskReleasedError, + type BrowserWorkerEvent, + type BrowserWorkerOptions, +} from "../browser/index.js"; + +// --------------------------------------------------------------------------- +// Fake engine API +// --------------------------------------------------------------------------- + +interface Call { + path: string; + body: Record; + headers: Record; + keepalive: boolean; + at: number; +} + +type Reply = { status: number; body?: unknown } | Promise<{ status: number; body?: unknown }>; + +function task(overrides: Record = {}) { + return { + id: "task-1", + instance_id: "inst-1", + block_id: "confirm", + handler_name: "confirm", + queue_name: null, + params: { orderId: "o-1", note: "PAGE-SECRET-NOTE" }, + context: { data: {} }, + attempt: 1, + timeout_ms: null, + state: "claimed", + worker_id: "tab-1", + claimed_at: null, + heartbeat_at: null, + completed_at: null, + output: null, + error_message: null, + error_retryable: null, + created_at: "2026-09-27T00:00:00Z", + claim_epoch: 7, + checkpoint_seq: 0, + effect_id: "eff-123", + ...overrides, + }; +} + +function fakeServer() { + const calls: Call[] = []; + let queue: unknown[][] = []; + const routes: Record Reply> = { + poll: () => ({ status: 200, body: { tasks: queue.shift() ?? [], lease_secs: 30 } }), + heartbeat: () => ({ status: 200, body: { checkpoint_seq: 0 } }), + complete: () => ({ status: 204 }), + fail: () => ({ status: 204 }), + release: () => ({ status: 204 }), + }; + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const path = String(url).replace("https://engine.test", ""); + const call: Call = { + path, + body: init?.body ? JSON.parse(String(init.body)) : {}, + headers: (init?.headers ?? {}) as Record, + keepalive: init?.keepalive === true, + at: Date.now(), + }; + calls.push(call); + const action = path.endsWith("/poll") ? "poll" : path.split("/").pop()!; + const reply = await routes[action](call); + return { + status: reply.status, + ok: reply.status >= 200 && reply.status < 300, + text: async () => (reply.body === undefined ? "" : JSON.stringify(reply.body)), + } as Response; + }); + return { + calls, + routes, + fetch: fetchImpl as unknown as typeof fetch, + enqueue: (...tasks: unknown[]) => queue.push(tasks), + reset: () => { queue = []; }, + of: (action: string) => calls.filter((c) => (action === "poll" ? c.path.endsWith("/poll") : c.path.endsWith(`/${action}`))), + }; +} + +async function flush(rounds = 20): Promise { + for (let i = 0; i < rounds; i++) await vi.advanceTimersByTimeAsync(0); +} + +function makeWorker(server: ReturnType, overrides: Partial = {}) { + const events: BrowserWorkerEvent[] = []; + const getToken = vi.fn(async () => ({ token: "tok-1", expiresAt: Date.now() + 15 * 60_000, runtimeId: "tab-1" })); + const worker = new BrowserWorker({ + baseUrl: "https://engine.test/", + getToken, + mode: "main", + fetch: server.fetch, + onEvent: (e) => events.push(e), + ...overrides, + }); + return { worker, events, getToken }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-27T12:00:00Z")); + vi.spyOn(Math, "random").mockReturnValue(1); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- + +describe("BrowserWorker", () => { + it("polls each handler with browser capabilities bound to the token's runtime", async () => { + const server = fakeServer(); + const { worker } = makeWorker(server); + worker.register("confirm", async () => ({})).register("read_page", async () => ({})); + await worker.start(); + await flush(); + + const polls = server.of("poll"); + expect(polls.map((p) => p.body.handler_name).sort()).toEqual(["confirm", "read_page"]); + const { body, headers } = polls[0]; + expect(headers["x-api-key"]).toBe("tok-1"); + expect(body.worker_id).toBe("tab-1"); + expect(body.limit).toBe(1); + const caps = body.capabilities as Record; + expect(caps).toMatchObject({ + runtime_id: "tab-1", + kind: "browser", + trust: "registered", + handlers: ["confirm", "read_page"], + offline_capable: false, + }); + const lifetime = Date.parse(caps.expires_at as string) - Date.parse(caps.observed_at as string); + expect(lifetime).toBeGreaterThan(0); + expect(lifetime).toBeLessThanOrEqual(300_000); + await worker.stop(); + }); + + it("caps the capability lifetime at the token expiry", async () => { + const server = fakeServer(); + const { worker } = makeWorker(server, { + getToken: async () => ({ token: "t", expiresAt: new Date(Date.now() + 90_000).toISOString(), runtimeId: "tab-1" }), + }); + worker.register("confirm", async () => ({})); + await worker.start(); + await flush(); + const caps = server.of("poll")[0].body.capabilities as Record; + expect(Date.parse(caps.expires_at)).toBe(Date.now() + 90_000); + await worker.stop(); + }); + + it("runs the handler with input and ctx, then completes with the claim epoch", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker } = makeWorker(server); + const seen: unknown[] = []; + worker.register("confirm", async (input, ctx) => { + seen.push(input, ctx.effectId, ctx.taskId, ctx.signal.aborted); + return { confirmed: true, pageTitle: document.title }; + }); + await worker.start(); + await flush(); + + expect(seen).toEqual([{ orderId: "o-1", note: "PAGE-SECRET-NOTE" }, "eff-123", "task-1", false]); + const [complete] = server.of("complete"); + expect(complete.path).toBe("/workers/tasks/task-1/complete"); + expect(complete.body).toEqual({ worker_id: "tab-1", claim_epoch: 7, output: { confirmed: true, pageTitle: "" } }); + await worker.stop(); + }); + + it("fails with the handler's error and retryable flag", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker } = makeWorker(server); + worker.register("confirm", async () => { + throw Object.assign(new Error("user declined"), { retryable: false }); + }); + await worker.start(); + await flush(); + expect(server.of("fail")[0].body).toEqual({ worker_id: "tab-1", claim_epoch: 7, message: "user declined", retryable: false }); + await worker.stop(); + }); + + it("heartbeats at a third of lease_secs, honouring per-task leases", async () => { + const server = fakeServer(); + server.enqueue(task(), task({ id: "task-2", lease_secs: 9 })); + const { worker } = makeWorker(server, { maxConcurrent: 2 }); + worker.register("confirm", () => new Promise(() => {})); + await worker.start(); + await flush(); + const start = Date.now(); + + await vi.advanceTimersByTimeAsync(20_500); + const beats = server.of("heartbeat"); + const offsets = (id: string) => beats.filter((b) => b.path.includes(`/${id}/`)).map((b) => b.at - start); + expect(offsets("task-1")).toEqual([10_000, 20_000]); + expect(offsets("task-2")).toEqual([3_000, 6_000, 9_000, 12_000, 15_000, 18_000]); + expect(beats[0].body).toEqual({ worker_id: "tab-1", claim_epoch: 7 }); + await worker.stop(); + }); + + it("aborts the handler signal on a 409 heartbeat and never acknowledges", async () => { + const server = fakeServer(); + server.enqueue(task()); + server.routes.heartbeat = () => ({ status: 409, body: { error: "stale claim" } }); + const { worker, events } = makeWorker(server); + let reason: unknown; + let finish!: () => void; + worker.register("confirm", (_input, ctx) => new Promise((resolve) => { + ctx.signal.addEventListener("abort", () => { reason = ctx.signal.reason; }); + finish = () => resolve({ late: true }); + })); + await worker.start(); + await flush(); + await vi.advanceTimersByTimeAsync(10_000); + await flush(); + + expect(reason).toBeInstanceOf(LeaseLostError); + expect(events).toContainEqual({ type: "task_aborted", taskId: "task-1", reason: "lease_lost" }); + finish(); + await flush(); + expect(server.of("complete")).toHaveLength(0); + expect(server.of("fail")).toHaveLength(0); + await worker.stop(); + }); + + it("ctx.heartbeat() extends the lease and rejects once the lease is lost", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker } = makeWorker(server); + const results: unknown[] = []; + worker.register("confirm", async (_input, ctx) => { + await ctx.heartbeat(); + results.push("ok"); + server.routes.heartbeat = () => ({ status: 409 }); + await ctx.heartbeat().catch((err) => results.push(err)); + return {}; + }); + await worker.start(); + await flush(); + expect(results[0]).toBe("ok"); + expect(results[1]).toBeInstanceOf(LeaseLostError); + expect(server.of("complete")).toHaveLength(0); + await worker.stop(); + }); + + it("releases in-flight tasks on pagehide with keepalive and pauses polling", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker, events } = makeWorker(server); + let abortReason: unknown; + worker.register("confirm", (_input, ctx) => new Promise(() => { + ctx.signal.addEventListener("abort", () => { abortReason = ctx.signal.reason; }); + })); + await worker.start(); + await flush(); + + window.dispatchEvent(new Event("pagehide")); + const [release] = server.of("release"); + // The request is issued synchronously inside the event handler. + expect(release.path).toBe("/workers/tasks/task-1/release"); + expect(release.keepalive).toBe(true); + expect(release.headers["x-api-key"]).toBe("tok-1"); + expect(release.body).toEqual({ worker_id: "tab-1", claim_epoch: 7, started: true }); + expect(abortReason).toBeInstanceOf(TaskReleasedError); + expect(events).toContainEqual({ type: "released", taskIds: ["task-1"], reason: "pagehide" }); + + const pollsBefore = server.of("poll").length; + await vi.advanceTimersByTimeAsync(30_000); + expect(server.of("poll")).toHaveLength(pollsBefore); + expect(server.of("heartbeat")).toHaveLength(0); + + // Restored from the back/forward cache: polling resumes. + const pageshow = new Event("pageshow") as Event & { persisted: boolean }; + Object.defineProperty(pageshow, "persisted", { value: true }); + window.dispatchEvent(pageshow); + await flush(); + expect(server.of("poll").length).toBeGreaterThan(pollsBefore); + await worker.stop(); + }); + + it("releases on visibilitychange→hidden and resumes when visible", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker } = makeWorker(server); + worker.register("confirm", () => new Promise(() => {})); + await worker.start(); + await flush(); + + const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + expect(server.of("release")).toHaveLength(1); + expect(server.of("release")[0].keepalive).toBe(true); + const pollsBefore = server.of("poll").length; + await vi.advanceTimersByTimeAsync(10_000); + expect(server.of("poll")).toHaveLength(pollsBefore); + + visibility.mockReturnValue("visible"); + document.dispatchEvent(new Event("visibilitychange")); + await flush(); + expect(server.of("poll").length).toBeGreaterThan(pollsBefore); + await worker.stop(); + }); + + it("honours releaseOn: ['pagehide'] by ignoring visibility changes", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker } = makeWorker(server, { releaseOn: ["pagehide"] }); + worker.register("confirm", () => new Promise(() => {})); + await worker.start(); + await flush(); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + expect(server.of("release")).toHaveLength(0); + await worker.stop(); + }); + + it("releases claims the page never started with started=false when paused mid-poll", async () => { + const server = fakeServer(); + let resolvePoll!: (reply: { status: number; body: unknown }) => void; + server.routes.poll = () => new Promise((resolve) => { resolvePoll = resolve; }); + const handler = vi.fn(async () => ({})); + const { worker } = makeWorker(server); + worker.register("confirm", handler); + await worker.start(); + await flush(); + + void worker.release("hidden"); + resolvePoll({ status: 200, body: { tasks: [task()] } }); + await flush(); + expect(handler).not.toHaveBeenCalled(); + expect(server.of("release")[0].body).toEqual({ worker_id: "tab-1", claim_epoch: 7, started: false }); + await worker.stop(); + }); + + it("refreshes the token before expiry and uses the new one", async () => { + const server = fakeServer(); + let n = 0; + const getToken = vi.fn(async () => { + n += 1; + return { token: `tok-${n}`, expires_at: new Date(Date.now() + 120_000).toISOString(), runtime_id: "tab-1" }; + }); + const { worker, events } = makeWorker(server, { getToken, pollIntervalMs: 5_000 }); + worker.register("confirm", async () => ({})); + await worker.start(); + await flush(); + expect(getToken).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(59_000); + expect(getToken).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1_000); + await flush(); + expect(getToken).toHaveBeenCalledTimes(2); + expect(events.some((e) => e.type === "token_refreshed")).toBe(true); + + await vi.advanceTimersByTimeAsync(5_000); + expect(server.calls.at(-1)!.headers["x-api-key"]).toBe("tok-2"); + // Still running after the first token's expiry. + await vi.advanceTimersByTimeAsync(70_000); + expect(worker.running).toBe(true); + await worker.stop(); + }); + + it("stops when the token expires without a successful refresh", async () => { + const server = fakeServer(); + server.enqueue(task()); + let calls = 0; + const getToken = vi.fn(async () => { + calls += 1; + if (calls > 1) throw new Error("backend unavailable"); + return { token: "tok-1", expiresAt: Date.now() + 120_000, runtimeId: "tab-1" }; + }); + const { worker, events } = makeWorker(server, { getToken }); + let aborted: unknown; + worker.register("confirm", (_input, ctx) => new Promise(() => { + ctx.signal.addEventListener("abort", () => { aborted = ctx.signal.reason; }); + })); + await worker.start(); + await flush(); + + await vi.advanceTimersByTimeAsync(121_000); + await flush(); + expect(getToken.mock.calls.length).toBeGreaterThan(1); + expect(worker.running).toBe(false); + expect(events).toContainEqual({ type: "stopped", reason: "token_expired" }); + expect(aborted).toBeDefined(); + const count = server.calls.length; + await vi.advanceTimersByTimeAsync(60_000); + expect(server.calls).toHaveLength(count); + }); + + it("asks for a new token when a poll is unauthorized", async () => { + const server = fakeServer(); + let first = true; + server.routes.poll = () => { + if (first) { first = false; return { status: 401, body: { error: "token revoked" } }; } + return { status: 200, body: { tasks: [] } }; + }; + const { worker, getToken } = makeWorker(server); + worker.register("confirm", async () => ({})); + await worker.start(); + await flush(); + expect(getToken).toHaveBeenCalledTimes(2); + await worker.stop(); + }); + + it("backs off exponentially on poll failures and recovers", async () => { + const server = fakeServer(); + let failing = true; + server.routes.poll = () => (failing ? { status: 503 } : { status: 200, body: { tasks: [] } }); + const { worker } = makeWorker(server, { pollIntervalMs: 1_000, maxBackoffMs: 8_000 }); + worker.register("confirm", async () => ({})); + await worker.start(); + await flush(); + await vi.advanceTimersByTimeAsync(2_000 + 4_000 + 8_000 + 8_000); + const times = server.of("poll").map((p) => p.at); + const gaps = times.slice(1).map((t, i) => t - times[i]); + expect(gaps).toEqual([2_000, 4_000, 8_000, 8_000]); + + failing = false; + await vi.advanceTimersByTimeAsync(8_000); + await vi.advanceTimersByTimeAsync(3_000); + const after = server.of("poll").map((p) => p.at); + expect(after.at(-1)! - after.at(-2)!).toBe(1_000); + await worker.stop(); + }); + + it("rejects oversized output with a clear, non-retryable failure", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker, events } = makeWorker(server); + worker.register("confirm", async () => ({ html: "x".repeat(1024 * 1024) })); + await worker.start(); + await flush(); + expect(server.of("complete")).toHaveLength(0); + const [fail] = server.of("fail"); + expect(fail.body.retryable).toBe(false); + expect(fail.body.message).toMatch(/exceeds the 1048576-byte limit/); + expect(events).toContainEqual({ type: "task_failed", taskId: "task-1", retryable: false }); + await worker.stop(); + }); + + it("honours a custom maxOutputBytes", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker } = makeWorker(server, { maxOutputBytes: 16 }); + worker.register("confirm", async () => ({ text: "more than sixteen bytes" })); + await worker.start(); + await flush(); + expect(server.of("fail")[0].body.message).toMatch(/exceeds the 16-byte limit/); + await worker.stop(); + }); + + it("turns a 413 on complete into a permanent failure instead of holding the claim", async () => { + const server = fakeServer(); + server.enqueue(task()); + server.routes.complete = () => ({ + status: 413, + body: { error: { code: "payload_too_large", message: "browser step output is 2000000 bytes; the maximum is 1048576" } }, + }); + const { worker } = makeWorker(server, { maxOutputBytes: 16 * 1024 * 1024 }); + worker.register("confirm", async () => ({ ok: true })); + await worker.start(); + await flush(); + expect(server.of("complete")).toHaveLength(1); + const fails = server.of("fail"); + expect(fails).toHaveLength(1); + expect(fails[0].body).toMatchObject({ worker_id: "tab-1", claim_epoch: 7, retryable: false }); + expect(fails[0].body.message).toMatch(/too large \(413\): browser step output is 2000000 bytes/); + await vi.advanceTimersByTimeAsync(60_000); + expect(server.of("complete")).toHaveLength(1); + expect(server.of("heartbeat")).toHaveLength(0); + await worker.stop(); + }); + + it("never runs a handler whose task was released before it started, and releases with started=false", async () => { + const server = fakeServer(); + server.enqueue(task()); + const handler = vi.fn(async () => ({})); + const { worker } = makeWorker(server, { + onEvent: (event) => { + if (event.type === "task_started") window.dispatchEvent(new Event("pagehide")); + }, + }); + worker.register("confirm", handler); + await worker.start(); + await flush(); + expect(handler).not.toHaveBeenCalled(); + expect(server.of("release")[0].body).toEqual({ worker_id: "tab-1", claim_epoch: 7, started: false }); + expect(server.of("complete")).toHaveLength(0); + expect(server.of("fail")).toHaveLength(0); + await worker.stop(); + }); + + it("fails a timed-out task as retryable and aborts its signal", async () => { + const server = fakeServer(); + server.enqueue(task({ timeout_ms: 5_000 })); + const { worker } = makeWorker(server); + let aborted = false; + worker.register("confirm", (_i, ctx) => new Promise(() => { + ctx.signal.addEventListener("abort", () => { aborted = true; }); + })); + await worker.start(); + await flush(); + await vi.advanceTimersByTimeAsync(5_000); + await flush(); + expect(aborted).toBe(true); + expect(server.of("fail")[0].body).toMatchObject({ message: "task timed out", retryable: true }); + await worker.stop(); + }); + + it("stop() releases in-flight work and detaches listeners", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker, events } = makeWorker(server); + worker.register("confirm", () => new Promise(() => {})); + await worker.start(); + await flush(); + await worker.stop(); + expect(server.of("release")[0].body).toMatchObject({ started: true }); + expect(events.at(-1)).toEqual({ type: "stopped", reason: "stopped" }); + const count = server.calls.length; + window.dispatchEvent(new Event("pagehide")); + await vi.advanceTimersByTimeAsync(60_000); + expect(server.calls).toHaveLength(count); + }); + + it("never exposes task input or output in events", async () => { + const server = fakeServer(); + server.enqueue(task()); + const { worker, events } = makeWorker(server); + worker.register("confirm", async () => ({ secretish: "OUTPUT-FROM-PAGE" })); + await worker.start(); + await flush(); + await worker.stop(); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain("PAGE-SECRET-NOTE"); + expect(serialized).not.toContain("OUTPUT-FROM-PAGE"); + expect(serialized).not.toContain("tok-1"); + }); + + it("validates configuration and token shape", async () => { + expect(() => new BrowserWorker({ baseUrl: "", getToken: async () => ({ token: "t", expiresAt: 1, runtimeId: "r" }) })).toThrow(/baseUrl/); + const server = fakeServer(); + const { worker } = makeWorker(server, { getToken: async () => ({ token: "", expiresAt: Date.now() + 1000, runtimeId: "r" }) }); + await expect(worker.start()).rejects.toThrow(/register at least one handler/); + worker.register("confirm", async () => ({})); + await expect(worker.start()).rejects.toThrow(/no token/); + expect(worker.running).toBe(false); + }); +}); diff --git a/src/__tests__/builder.test.ts b/src/__tests__/builder.test.ts index c86d5da..1d8e7ae 100644 --- a/src/__tests__/builder.test.ts +++ b/src/__tests__/builder.test.ts @@ -224,4 +224,25 @@ describe("workflow() builder", () => { const wf = workflow("x", "tenant_a").step("s", "h").build(); expect(wf.namespace).toBe("tenant_a"); }); + + it("emits runtime placement as params.$runtime, not as a block field", () => { + const wf = workflow("review") + .step("confirm", "confirm_in_page", { orderId: "o-1" }, { + runtime: { runtime_kinds: ["browser"], requires_human_ui: true }, + timeout: 60_000, + }) + .step("targeted", "read_page", undefined, { runtime: { runtime_kinds: ["browser"], runtime_id: "tab-1" } }) + .build(); + expect(wf.blocks[0]).toEqual({ + type: "step", + id: "confirm", + handler: "confirm_in_page", + params: { orderId: "o-1", $runtime: { runtime_kinds: ["browser"], requires_human_ui: true } }, + timeout: 60_000, + }); + expect(wf.blocks[0]).not.toHaveProperty("runtime"); + expect((wf.blocks[1] as { params: unknown }).params).toEqual({ + $runtime: { runtime_kinds: ["browser"], runtime_id: "tab-1" }, + }); + }); }); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index c7d406c..4902e3d 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1051,6 +1051,39 @@ describe("Orch8Client", () => { // ---- Workers (additional) ----------------------------------------------- describe("Workers (additional)", () => { + it("createBrowserSession POSTs snake_case and returns a camelCase session", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ + token: "bt_1", runtime_id: "tab-1", expires_at: "2026-09-27T12:15:00Z", handlers: ["confirm"], + })); + + const session = await client.createBrowserSession({ + runtimeId: "tab-1", handlers: ["confirm"], ttlSecs: 600, queues: ["ui"], + }); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("http://localhost:8080/runtimes/browser-sessions"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ + handlers: ["confirm"], runtime_id: "tab-1", ttl_secs: 600, queues: ["ui"], + }); + expect(session).toEqual({ + token: "bt_1", runtimeId: "tab-1", expiresAt: "2026-09-27T12:15:00Z", handlers: ["confirm"], + }); + }); + + it("createBrowserSession rejects an empty handler allowlist", async () => { + await expect(client.createBrowserSession({ handlers: [] })).rejects.toThrow(/at least one handler/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("releaseTask POSTs to /workers/tasks/:id/release", async () => { + mockFetch.mockResolvedValueOnce(noContentResponse()); + await client.releaseTask("task-1", { worker_id: "w", claim_epoch: 2, started: false }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("http://localhost:8080/workers/tasks/task-1/release"); + expect(JSON.parse(init.body)).toEqual({ worker_id: "w", claim_epoch: 2, started: false }); + }); + it("listWorkerTasks GETs /workers/tasks without filter", async () => { const tasks = [{ id: "task-1", handler_name: "my-handler" }]; mockFetch.mockResolvedValueOnce(jsonResponse(tasks)); diff --git a/src/__tests__/frameworks.test.ts b/src/__tests__/frameworks.test.ts index b31ae5c..8563153 100644 --- a/src/__tests__/frameworks.test.ts +++ b/src/__tests__/frameworks.test.ts @@ -6,6 +6,7 @@ import { Module, Injectable } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { describe, expect, it } from "vitest"; import type { WorkerTask } from "../types.js"; +import { workerTaskContext } from "../worker.js"; import { FakeOrch8Server } from "../testing/index.js"; import { getOrch8, orch8Express, orch8RawBody } from "../express/index.js"; import { @@ -96,7 +97,7 @@ describe("NestJS module", () => { it("discovers @Orch8Handler methods bound to their instance", async () => { const handlers = discoverHandlers([new EmailHandlers("yo")]); expect(Object.keys(handlers)).toEqual(["greet"]); - expect(await handlers.greet({ params: { name: "Ada" } } as WorkerTask)).toEqual({ greeting: "yo Ada" }); + expect(await handlers.greet({ params: { name: "Ada" } } as WorkerTask, workerTaskContext({} as WorkerTask, "w"))).toEqual({ greeting: "yo Ada" }); expect(() => discoverHandlers([new EmailHandlers(), new (class extends EmailHandlers {})()])).toThrow(/duplicate/); }); diff --git a/src/__tests__/serverless.test.ts b/src/__tests__/serverless.test.ts new file mode 100644 index 0000000..863c735 --- /dev/null +++ b/src/__tests__/serverless.test.ts @@ -0,0 +1,445 @@ +import { resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { build } from "esbuild"; +import { Orch8Client } from "../client.js"; +import { + createCloudflareExecutor, + createLambdaExecutor, + drainOnce, + type ServerlessTaskContext, +} from "../serverless/index.js"; +import type { WorkerTask } from "../types.js"; + +// Mock engine for the worker lease API: pending tasks per handler, claimed on +// poll, and a log of every lease call the executor makes. + +const T0 = new Date("2026-09-28T12:00:00Z").getTime(); + +function task(id: string, extra: Partial = {}): WorkerTask { + return { + id, + instance_id: `i-${id}`, + block_id: "b-1", + handler_name: "scan", + queue_name: null, + params: { id }, + context: {}, + attempt: 1, + timeout_ms: null, + state: "pending", + worker_id: null, + claimed_at: null, + heartbeat_at: null, + completed_at: null, + output: null, + error_message: null, + error_retryable: null, + created_at: "2026-01-01T00:00:00Z", + checkpoint_seq: 0, + ...extra, + }; +} + +interface Call { + path: string; + body: Record; + at: number; +} + +class MockEngine { + pending: WorkerTask[] = []; + calls: Call[] = []; + leaseSecs = 30; + pollFails = false; + /** Task ids whose heartbeat answers 409. */ + lostLeases = new Set(); + private epoch = 0; + + readonly fetch = vi.fn(async (url: string, init?: RequestInit): Promise => { + const path = new URL(url).pathname.replace(/^\/api\/v1/, ""); + const body = init?.body ? JSON.parse(String(init.body)) : {}; + this.calls.push({ path, body, at: Date.now() }); + if (path === "/workers/tasks/poll" || path === "/workers/tasks/poll/queue") { + if (this.pollFails) return this.json(503, { error: "unavailable" }); + const matching = this.pending.filter((t) => t.handler_name === body.handler_name); + const claimed = matching.slice(0, body.limit ?? 1); + this.pending = this.pending.filter((t) => !claimed.includes(t)); + return this.json(200, { + tasks: claimed.map((t) => ({ ...t, state: "claimed", worker_id: body.worker_id, claim_epoch: ++this.epoch })), + lease_secs: this.leaseSecs, + }); + } + const m = path.match(/^\/workers\/tasks\/([^/]+)\/(heartbeat|complete|fail|release)$/); + if (m) { + if (m[2] === "heartbeat" && this.lostLeases.has(decodeURIComponent(m[1]))) { + return this.json(409, { error: "claim_epoch_mismatch" }); + } + return m[2] === "heartbeat" ? this.json(200, { checkpoint_seq: 0 }) : this.json(204); + } + return this.json(404, { error: "not_found" }); + }); + + json(status: number, body?: unknown): Response { + return new Response(status === 204 || body === undefined ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + } + + client(): Orch8Client { + return new Orch8Client({ baseUrl: "https://engine.test", fetch: this.fetch as unknown as typeof fetch, retry: false }); + } + + of(action: string): Call[] { + return this.calls.filter((c) => c.path.endsWith(`/${action}`)); + } +} + +let engine: MockEngine; + +beforeEach(() => { + engine = new MockEngine(); + vi.useFakeTimers(); + vi.setSystemTime(T0); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("drainOnce", () => { + it("claims up to maxTasks, runs them and completes with the claim epoch", async () => { + engine.pending = [task("a"), task("b"), task("c")]; + const seen: string[] = []; + const result = await drainOnce({ + client: engine.client(), + workerId: "w-1", + handlers: { scan: async (t) => { seen.push(t.id); return { ok: t.id }; } }, + maxTasks: 2, + deadlineMs: T0 + 30_000, + }); + + expect(seen).toEqual(["a", "b"]); + expect(result).toMatchObject({ claimed: 2, completed: 2, stoppedBy: "max_tasks", workerId: "w-1" }); + const poll = engine.of("poll")[0]; + expect(poll.body).toMatchObject({ handler_name: "scan", worker_id: "w-1", limit: 2 }); + expect(poll.body.capabilities).toBeUndefined(); + const completes = engine.of("complete"); + expect(completes.map((c) => c.body)).toEqual([ + { worker_id: "w-1", claim_epoch: 1, output: { ok: "a" } }, + { worker_id: "w-1", claim_epoch: 2, output: { ok: "b" } }, + ]); + expect(engine.pending.map((t) => t.id)).toEqual(["c"]); + }); + + it("keeps polling in rounds until the queue is empty", async () => { + engine.pending = [task("a")]; + let calls = 0; + const result = await drainOnce({ + client: engine.client(), + handlers: { + scan: async () => { + calls += 1; + if (calls === 1) engine.pending.push(task("b")); + return {}; + }, + }, + deadlineMs: T0 + 30_000, + }); + expect(result).toMatchObject({ claimed: 2, completed: 2, stoppedBy: "empty" }); + expect(engine.of("poll")).toHaveLength(3); + }); + + it("polls every handler and advertises capabilities with the default kind", async () => { + engine.pending = [task("a", { handler_name: "resize" })]; + const result = await drainOnce({ + client: engine.client(), + workerId: "w-2", + handlers: { scan: async () => ({}), resize: async () => ({ resized: true }) }, + capabilities: { regions: ["eu-west-1"] }, + defaultKind: "edge", + deadlineMs: T0 + 30_000, + }); + expect(result.completed).toBe(1); + const [first] = engine.of("poll"); + expect(first.body.capabilities).toMatchObject({ + runtime_id: "w-2", + kind: "edge", + trust: "registered", + regions: ["eu-west-1"], + handlers: ["scan", "resize"], + }); + expect(new Set(engine.of("poll").map((c) => c.body.handler_name))).toEqual(new Set(["scan", "resize"])); + }); + + it("releases a still-running task with started: true before the deadline and never acknowledges it", async () => { + engine.pending = [task("slow")]; + let signal: AbortSignal | undefined; + let finish: (() => void) | undefined; + const running = drainOnce({ + client: engine.client(), + workerId: "w-3", + handlers: { + scan: (_t, ctx: ServerlessTaskContext) => { + signal = ctx.signal; + return new Promise((resolve) => { finish = () => resolve({ late: true }); }); + }, + }, + deadlineMs: T0 + 10_000, + releaseMarginMs: 1_500, + }); + + await vi.advanceTimersByTimeAsync(8_400); + expect(engine.of("release")).toHaveLength(0); + await vi.advanceTimersByTimeAsync(200); + const result = await running; + + const [release] = engine.of("release"); + expect(release.body).toEqual({ worker_id: "w-3", claim_epoch: 1, started: true }); + expect(release.at).toBeLessThanOrEqual(T0 + 10_000 - 1_500); + expect(signal?.aborted).toBe(true); + expect(result).toMatchObject({ claimed: 1, released: 1, completed: 0, stoppedBy: "deadline" }); + expect(result.tasks[0]).toMatchObject({ task_id: "slow", outcome: "released" }); + + finish?.(); + await vi.advanceTimersByTimeAsync(1_000); + expect(engine.of("complete")).toHaveLength(0); + expect(engine.of("fail")).toHaveLength(0); + }); + + it("releases unstarted a claim whose timeout cannot fit the remaining budget", async () => { + engine.pending = [task("long", { timeout_ms: 60_000 })]; + const handler = vi.fn(async () => ({})); + const result = await drainOnce({ + client: engine.client(), + workerId: "w-4", + handlers: { scan: handler }, + deadlineMs: T0 + 20_000, + }); + expect(handler).not.toHaveBeenCalled(); + expect(engine.of("release")[0].body).toEqual({ worker_id: "w-4", claim_epoch: 1, started: false }); + expect(result).toMatchObject({ released: 1, stoppedBy: "deadline" }); + expect(engine.of("poll")).toHaveLength(1); + }); + + it("does not claim when the budget is already below minTaskBudgetMs", async () => { + engine.pending = [task("a")]; + const result = await drainOnce({ + client: engine.client(), + handlers: { scan: async () => ({}) }, + deadlineMs: T0 + 1_500, + releaseMarginMs: 1_000, + minTaskBudgetMs: 1_000, + }); + expect(result).toMatchObject({ claimed: 0, stoppedBy: "deadline" }); + expect(engine.calls).toHaveLength(0); + }); + + it("heartbeats at half the lease while a task runs", async () => { + engine.pending = [task("hb")]; + engine.leaseSecs = 4; + let finish: (() => void) | undefined; + const running = drainOnce({ + client: engine.client(), + workerId: "w-5", + handlers: { scan: () => new Promise((resolve) => { finish = () => resolve({}); }) }, + deadlineMs: T0 + 60_000, + }); + await vi.advanceTimersByTimeAsync(6_100); + const beats = engine.of("heartbeat"); + expect(beats).toHaveLength(3); + expect(beats[0].body).toEqual({ worker_id: "w-5", claim_epoch: 1 }); + finish?.(); + const result = await running; + expect(result.completed).toBe(1); + }); + + it("stops and never acknowledges a task whose lease was lost", async () => { + engine.pending = [task("gone")]; + engine.leaseSecs = 2; + engine.lostLeases.add("gone"); + let signal: AbortSignal | undefined; + const running = drainOnce({ + client: engine.client(), + handlers: { + scan: (_t, ctx) => { + signal = ctx.signal; + return new Promise(() => undefined); + }, + }, + deadlineMs: T0 + 60_000, + }); + await vi.advanceTimersByTimeAsync(1_100); + const result = await running; + expect(signal?.aborted).toBe(true); + expect(result).toMatchObject({ leaseLost: 1, completed: 0, failed: 0, released: 0 }); + expect(engine.of("complete")).toHaveLength(0); + expect(engine.of("release")).toHaveLength(0); + }); + + it("reports handler errors with the retryable flag", async () => { + engine.pending = [task("x"), task("y")]; + const result = await drainOnce({ + client: engine.client(), + handlers: { + scan: async (t) => { + if (t.id === "x") throw new Error("boom"); + throw Object.assign(new Error("bad input"), { retryable: false }); + }, + }, + deadlineMs: T0 + 30_000, + }); + expect(result.failed).toBe(2); + expect(engine.of("fail").map((c) => [c.body.message, c.body.retryable])).toEqual([ + ["boom", true], + ["bad input", false], + ]); + }); + + it("fails a task that exceeds its own timeout_ms", async () => { + engine.pending = [task("t", { timeout_ms: 2_000 })]; + const running = drainOnce({ + client: engine.client(), + handlers: { scan: () => new Promise(() => undefined) }, + deadlineMs: T0 + 30_000, + }); + await vi.advanceTimersByTimeAsync(2_100); + const result = await running; + expect(result.failed).toBe(1); + expect(engine.of("fail")[0].body).toMatchObject({ message: "task timed out", retryable: true }); + }); + + it("claims through the queue endpoint when queueName is set", async () => { + engine.pending = [task("q")]; + await drainOnce({ + client: engine.client(), + handlers: { scan: async () => ({}) }, + queueName: "edge", + deadlineMs: T0 + 30_000, + }); + expect(engine.of("queue")[0].body).toMatchObject({ queue_name: "edge", handler_name: "scan" }); + }); + + it("returns poll_error instead of throwing when every poll fails", async () => { + engine.pollFails = true; + const result = await drainOnce({ + client: engine.client(), + handlers: { scan: async () => ({}) }, + deadlineMs: T0 + 30_000, + }); + expect(result.stoppedBy).toBe("poll_error"); + expect(result.pollErrors[0]).toMatch(/^scan: Orch8 API error 503/); + }); +}); + +describe("createLambdaExecutor", () => { + it("derives the budget from the remaining invocation time minus the safety margin", async () => { + engine.pending = [task("l")]; + const handler = createLambdaExecutor({ + client: engine.client(), + handlers: { scan: () => new Promise(() => undefined) }, + safetyMarginMs: 3_000, + releaseMarginMs: 1_000, + }); + const running = handler({ source: "aws.events" }, { getRemainingTimeInMillis: () => 10_000 }); + await vi.advanceTimersByTimeAsync(6_000); + const result = await running; + expect(result.workerId).toMatch(/^lambda-/); + const [release] = engine.of("release"); + expect(release.body).toMatchObject({ started: true }); + expect(release.at - T0).toBe(6_000); + expect(release.at - T0).toBeLessThan(10_000); + }); + + it("keeps one worker id per container and builds options lazily once", async () => { + const factory = vi.fn(() => ({ client: engine.client(), handlers: { scan: async () => ({}) } })); + const handler = createLambdaExecutor(factory); + const ctx = { getRemainingTimeInMillis: () => 30_000 }; + const a = await handler({}, ctx); + const b = await handler({}, ctx); + expect(factory).toHaveBeenCalledTimes(1); + expect(a.workerId).toBe(b.workerId); + }); +}); + +describe("createCloudflareExecutor", () => { + interface Env { SECRET?: string } + const make = (secret?: string) => + createCloudflareExecutor((env: Env) => ({ + client: engine.client(), + handlers: { scan: async () => ({ done: true }) }, + capabilities: {}, + triggerSecret: env.SECRET ?? secret, + budgetMs: 20_000, + })); + + it("disables the fetch trigger without a secret", async () => { + const res = await make().fetch(new Request("https://w.test/", { method: "POST" }), {}); + expect(res.status).toBe(404); + expect(engine.calls).toHaveLength(0); + }); + + it("rejects wrong methods and credentials", async () => { + const exec = make("s3cret"); + expect((await exec.fetch(new Request("https://w.test/"), {})).status).toBe(405); + const bad = await exec.fetch( + new Request("https://w.test/", { method: "POST", headers: { authorization: "Bearer nope" } }), + {}, + ); + expect(bad.status).toBe(401); + expect(engine.calls).toHaveLength(0); + }); + + it("drains on an authorised fetch and advertises kind edge", async () => { + engine.pending = [task("cf")]; + const res = await make("s3cret").fetch( + new Request("https://w.test/", { method: "POST", headers: { authorization: "Bearer s3cret" } }), + {}, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ claimed: 1, completed: 1, stoppedBy: "empty" }); + expect(engine.of("poll")[0].body.capabilities).toMatchObject({ kind: "edge" }); + expect(body.workerId).toMatch(/^cf-/); + }); + + it("runs the scheduled trigger under waitUntil", async () => { + engine.pending = [task("cron")]; + const waitUntil = vi.fn(); + await make().scheduled({ cron: "*/1 * * * *" }, {}, { waitUntil }); + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(engine.of("complete")).toHaveLength(1); + }); + + it("releases before the configured budget ends", async () => { + engine.pending = [task("slow")]; + const exec = createCloudflareExecutor({ + client: engine.client(), + handlers: { scan: () => new Promise(() => undefined) }, + budgetMs: 5_000, + releaseMarginMs: 500, + }); + const run = exec.drain({}); + await vi.advanceTimersByTimeAsync(4_500); + const result = await run; + expect(result.released).toBe(1); + expect(engine.of("release")[0].at - T0).toBe(4_500); + }); +}); + +describe("serverless entry", () => { + it("bundles for an edge runtime without Node built-ins", async () => { + vi.useRealTimers(); + const out = await build({ + entryPoints: [resolve(process.cwd(), "src/serverless/index.ts")], + bundle: true, + platform: "browser", + format: "esm", + write: false, + logLevel: "silent", + }); + const code = out.outputFiles[0].text; + expect(code).not.toMatch(/from\s*["']node:/); + expect(code).not.toMatch(/import\(["']node:/); + }); +}); diff --git a/src/__tests__/worker-runtime.test.ts b/src/__tests__/worker-runtime.test.ts new file mode 100644 index 0000000..5de8a75 --- /dev/null +++ b/src/__tests__/worker-runtime.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Orch8Worker, workerTaskContext, type WorkerTaskContext } from "../worker.js"; +import type { WorkerTask } from "../types.js"; + +// Capability advertisement, lease context and release-on-shutdown for the +// Node worker (distributed-execution contract v1). + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +function json(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(body === undefined ? "" : JSON.stringify(body)), + } as unknown as Response; +} + +function task(id: string, extra: Partial = {}): WorkerTask { + return { + id, + instance_id: "i-1", + block_id: "b-1", + handler_name: "scan", + queue_name: null, + params: {}, + context: {}, + attempt: 1, + timeout_ms: null, + state: "claimed", + worker_id: "w-1", + claimed_at: null, + heartbeat_at: null, + completed_at: null, + output: null, + error_message: null, + error_retryable: null, + created_at: "2026-01-01T00:00:00Z", + claim_epoch: 4, + checkpoint_seq: 0, + ...extra, + }; +} + +interface Call { + url: string; + body: Record; +} + +/** Route requests by path; `polls` are served in order, then empty batches. */ +function route(polls: unknown[]): Call[] { + const calls: Call[] = []; + mockFetch.mockImplementation(async (url: string, init: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + calls.push({ url, body }); + if (url.endsWith("/workers/tasks/poll")) return json(polls.shift() ?? { tasks: [] }); + return json({}); + }); + return calls; +} + +describe("Orch8Worker runtime node support", () => { + beforeEach(() => { + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-28T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + async function stop(worker: Orch8Worker): Promise { + const stopping = worker.stop(); + await vi.advanceTimersByTimeAsync(35_000); + await stopping; + } + + it("sends a fresh capability advertisement bound to the worker id", async () => { + const calls = route([{ tasks: [] }]); + const worker = new Orch8Worker({ + engineUrl: "http://engine", + workerId: "w-1", + handlers: { scan: vi.fn(), ocr: vi.fn() }, + capabilities: { kind: "desktop", hardware: ["gpu"], regions: ["norway"], ttlSecs: 900 }, + }); + await worker.start(); + await vi.advanceTimersByTimeAsync(0); + + const poll = calls.find((c) => c.url.endsWith("/workers/tasks/poll"))!; + expect(poll.body.worker_id).toBe("w-1"); + expect(poll.body.capabilities).toEqual({ + runtime_id: "w-1", + kind: "desktop", + trust: "registered", + handlers: ["scan", "ocr"], + hardware: ["gpu"], + regions: ["norway"], + observed_at: "2026-09-28T12:00:00.000Z", + // ttlSecs is capped at the server's five-minute maximum. + expires_at: "2026-09-28T12:05:00.000Z", + }); + await stop(worker); + }); + + it("omits capabilities when none are configured", async () => { + const calls = route([]); + const worker = new Orch8Worker({ engineUrl: "http://engine", workerId: "w-1", handlers: { scan: vi.fn() } }); + await worker.start(); + await vi.advanceTimersByTimeAsync(0); + expect(calls[0]!.body).not.toHaveProperty("capabilities"); + await stop(worker); + }); + + it("passes effect_id, lease_secs and continuity_epoch to the handler", async () => { + const seen: WorkerTaskContext[] = []; + const calls = route([ + { tasks: [task("t-1", { effect_id: "eff-1", lease_secs: 120, continuity_epoch: 3 }), task("t-2")] }, + ]); + const worker = new Orch8Worker({ + engineUrl: "http://engine", + workerId: "w-1", + handlers: { + scan: async (_task, ctx) => { + seen.push(ctx); + return { ok: true }; + }, + }, + }); + await worker.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(seen).toEqual([ + { effectId: "eff-1", leaseSecs: 120, continuityEpoch: 3, workerId: "w-1" }, + { effectId: null, leaseSecs: null, continuityEpoch: null, workerId: "w-1" }, + ]); + expect(calls.filter((c) => c.url.endsWith("/complete")).map((c) => c.url)).toEqual([ + "http://engine/workers/tasks/t-1/complete", + "http://engine/workers/tasks/t-2/complete", + ]); + await stop(worker); + }); + + it("heartbeats within half of a task's lease", async () => { + let finish!: () => void; + const calls = route([{ tasks: [task("t-1", { lease_secs: 4 })] }]); + const worker = new Orch8Worker({ + engineUrl: "http://engine", + workerId: "w-1", + heartbeatIntervalMs: 15_000, + handlers: { scan: () => new Promise((resolve) => (finish = resolve)) }, + }); + await worker.start(); + await vi.advanceTimersByTimeAsync(2_100); + const beats = calls.filter((c) => c.url.endsWith("/t-1/heartbeat")); + expect(beats.length).toBeGreaterThanOrEqual(1); + expect(beats[0]!.body).toEqual({ worker_id: "w-1", claim_epoch: 4 }); + finish(); + await stop(worker); + }); + + it("releases tasks claimed beyond free capacity instead of orphaning them", async () => { + const calls = route([{ tasks: [task("t-1"), task("t-2"), task("t-3")] }]); + const handler = vi.fn(() => new Promise(() => {})); + const worker = new Orch8Worker({ + engineUrl: "http://engine", + workerId: "w-1", + maxConcurrent: 1, + handlers: { scan: handler }, + }); + await worker.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(handler).toHaveBeenCalledTimes(1); + const releases = calls.filter((c) => c.url.endsWith("/release")); + expect(releases.map((c) => c.url)).toEqual([ + "http://engine/workers/tasks/t-2/release", + "http://engine/workers/tasks/t-3/release", + ]); + expect(releases[0]!.body).toEqual({ worker_id: "w-1", claim_epoch: 4, started: false }); + // Do not wait for the never-settling handler; drop timers instead. + vi.clearAllTimers(); + }); + + it("releases a batch that arrives after stop() without running it", async () => { + let answerPoll!: (r: Response) => void; + const calls: Call[] = []; + mockFetch.mockImplementation((url: string, init: RequestInit) => { + calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + if (url.endsWith("/workers/tasks/poll") && calls.length === 1) { + return new Promise((resolve) => (answerPoll = resolve)); + } + return Promise.resolve(json({})); + }); + const handler = vi.fn(async () => ({})); + const worker = new Orch8Worker({ engineUrl: "http://engine", workerId: "w-1", handlers: { scan: handler } }); + await worker.start(); + await vi.advanceTimersByTimeAsync(0); + + const stopping = worker.stop(); + answerPoll(json({ tasks: [task("t-9", { claim_epoch: 7 })] })); + await vi.advanceTimersByTimeAsync(35_000); + await stopping; + + expect(handler).not.toHaveBeenCalled(); + const release = calls.find((c) => c.url.endsWith("/t-9/release")); + expect(release?.body).toEqual({ worker_id: "w-1", claim_epoch: 7, started: false }); + }); + + it("tolerates servers without /release", async () => { + const calls: Call[] = []; + let polled = false; + mockFetch.mockImplementation(async (url: string, init: RequestInit) => { + calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + if (url.endsWith("/workers/tasks/poll")) { + if (polled) return json({ tasks: [] }); + polled = true; + return json({ tasks: [task("t-1"), task("t-2")] }); + } + if (url.endsWith("/release")) return json({ error: "not found" }, 404); + return json({}); + }); + const worker = new Orch8Worker({ + engineUrl: "http://engine", + workerId: "w-1", + maxConcurrent: 1, + handlers: { scan: async () => ({}) }, + }); + await worker.start(); + await vi.advanceTimersByTimeAsync(0); + expect(calls.some((c) => c.url.endsWith("/t-2/release"))).toBe(true); + expect(calls.some((c) => c.url.endsWith("/t-1/complete"))).toBe(true); + await stop(worker); + }); + + it("workerTaskContext ignores malformed fields", () => { + expect( + workerTaskContext(task("t", { effect_id: 5 as unknown as string, lease_secs: "x" as unknown as number }), "w"), + ).toEqual({ effectId: null, leaseSecs: null, continuityEpoch: null, workerId: "w" }); + }); +}); diff --git a/src/browser/engine.ts b/src/browser/engine.ts new file mode 100644 index 0000000..cbee229 --- /dev/null +++ b/src/browser/engine.ts @@ -0,0 +1,584 @@ +/** + * The browser lease engine: poll(claim) -> heartbeat* -> complete | fail, + * with token refresh scheduling and exponential backoff. + * + * The same code runs in two places: directly on the main thread (fallback + * mode) and inside the dedicated Web Worker, whose source is pre-bundled + * into `lease-worker-source.ts` by `npm run generate:lease-worker`. Rerun + * that script after changing this file; a test fails when it is stale. + * Consumer bundlers only ever see the worker as an opaque string, so their + * transforms (minification, keep-names helpers, down-levelling) cannot + * break it. + */ +import type { EngineCommand, EngineConfig, EngineController, EngineEvent, EngineHost } from "./protocol.js"; +import type { WorkerTask } from "../types.js"; + +export function leaseEngine(host: EngineHost): EngineController { + type Timer = ReturnType; + interface Entry { + task: WorkerTask; + workerId: string; + hbMs: number; + hbTimer: Timer | null; + acking: boolean; + } + interface HttpResult { + status: number; + body: unknown; + } + + let cfg: EngineConfig | null = null; + let token: { token: string; expiresAt: number; runtimeId: string } | null = null; + let running = false; + let paused = false; + let stopped = false; + const pollTimers = new Map(); + const pollInFlight = new Map(); + const failures = new Map(); + const hints = new Map(); + const tasks = new Map(); + let refreshTimer: Timer | null = null; + let expiryTimer: Timer | null = null; + let tokenRetryTimer: Timer | null = null; + let tokenRequested = false; + let tokenFailures = 0; + + function now(): number { + return host.now ? host.now() : Date.now(); + } + + function emit(event: EngineEvent): void { + try { + host.emit(event); + } catch (_err) { + // Observers must never break the lease loop. + } + } + + function log( + level: "debug" | "info" | "warn" | "error", + message: string, + fields?: Record, + ): void { + emit({ type: "log", level, message, fields: fields || {} }); + } + + function tokenValid(): boolean { + return token !== null && token.expiresAt > now(); + } + + function post(path: string, body: unknown, keepalive?: boolean): Promise { + if (!cfg || !token) return Promise.reject(new Error("engine has no credential")); + const init: RequestInit = { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": token.token, + }, + body: JSON.stringify(body), + }; + if (keepalive) init.keepalive = true; + let timer: Timer | null = null; + if (typeof AbortController === "function" && !keepalive) { + const controller = new AbortController(); + init.signal = controller.signal; + timer = setTimeout(() => controller.abort(), cfg.requestTimeoutMs); + } + return host + .fetch(cfg.baseUrl + path, init) + .then((res) => { + if (res.status === 204) return { status: 204, body: null }; + return res.text().then( + (text) => { + let parsed: unknown = null; + if (text) { + try { + parsed = JSON.parse(text); + } catch (_err) { + parsed = null; + } + } + return { status: res.status, body: parsed }; + }, + () => ({ status: res.status, body: null }), + ); + }) + .then( + (result) => { + if (timer) clearTimeout(timer); + return result; + }, + (err) => { + if (timer) clearTimeout(timer); + throw err; + }, + ); + } + + function ok(status: number): boolean { + return status >= 200 && status < 300; + } + + function taskPath(id: string, action: string): string { + return "/workers/tasks/" + encodeURIComponent(id) + "/" + action; + } + + // --------------------------------------------------------------------------- + // Token lifecycle + // --------------------------------------------------------------------------- + + function requestToken(): void { + if (stopped || tokenRequested) return; + tokenRequested = true; + emit({ type: "needToken" }); + } + + function clearTokenTimers(): void { + if (refreshTimer) clearTimeout(refreshTimer); + if (expiryTimer) clearTimeout(expiryTimer); + if (tokenRetryTimer) clearTimeout(tokenRetryTimer); + refreshTimer = null; + expiryTimer = null; + tokenRetryTimer = null; + } + + function onToken(command: { token: string; expiresAt: number; runtimeId: string }): void { + if (stopped) return; + tokenRequested = false; + tokenFailures = 0; + clearTokenTimers(); + token = { token: command.token, expiresAt: command.expiresAt, runtimeId: command.runtimeId }; + const remaining = token.expiresAt - now(); + if (remaining <= 0) { + expire(); + return; + } + const margin = Math.min(cfg ? cfg.refreshMarginMs : 60000, remaining / 2); + refreshTimer = setTimeout(requestToken, Math.max(0, remaining - margin)); + expiryTimer = setTimeout(expire, remaining); + ensurePolling(); + } + + function onTokenError(message: string): void { + if (stopped) return; + tokenRequested = false; + tokenFailures += 1; + log("warn", "token refresh failed", { failures: tokenFailures, error: message.slice(0, 200) }); + const delay = Math.min(1000 * Math.pow(2, tokenFailures - 1), 30000); + if (tokenRetryTimer) clearTimeout(tokenRetryTimer); + tokenRetryTimer = setTimeout(() => { + tokenRetryTimer = null; + requestToken(); + }, delay); + } + + function expire(): void { + if (tokenValid()) return; + stop("token_expired"); + } + + function onAuthError(): void { + // The token may have been revoked or rotated; ask for a new one. + requestToken(); + } + + // --------------------------------------------------------------------------- + // Polling + // --------------------------------------------------------------------------- + + function canPoll(): boolean { + return running && !paused && !stopped && tokenValid(); + } + + function ensurePolling(): void { + if (!cfg || !canPoll()) return; + cfg.handlers.forEach((handler) => { + if (!pollTimers.has(handler) && !pollInFlight.get(handler)) schedulePoll(handler, 0); + }); + } + + function schedulePoll(handler: string, delay: number): void { + const existing = pollTimers.get(handler); + if (existing) clearTimeout(existing); + pollTimers.delete(handler); + if (!canPoll()) return; + pollTimers.set( + handler, + setTimeout(() => { + pollTimers.delete(handler); + pollOnce(handler); + }, delay), + ); + } + + function nextDelay(handler: string): number { + const c = cfg as EngineConfig; + const failed = failures.get(handler) || 0; + if (failed > 0) { + const base = Math.min(c.pollIntervalMs * Math.pow(2, failed), c.maxBackoffMs); + // Equal jitter: spread reconnect storms without shrinking below half. + return Math.round(base / 2 + (Math.random() * base) / 2); + } + return Math.max(c.pollIntervalMs, hints.get(handler) || 0); + } + + function capabilities(runtimeId: string): Record { + const c = cfg as EngineConfig; + const observed = now(); + const expires = Math.min(observed + c.capabilityTtlSecs * 1000, (token as { expiresAt: number }).expiresAt); + const caps: Record = { + runtime_id: runtimeId, + kind: "browser", + trust: c.trust, + handlers: c.handlers.slice(), + offline_capable: false, + observed_at: new Date(observed).toISOString(), + expires_at: new Date(expires).toISOString(), + }; + if (c.connectivity) caps.connectivity = c.connectivity; + return caps; + } + + function pollOnce(handler: string): void { + if (!canPoll() || !cfg || !token) return; + const c = cfg; + const slots = c.maxConcurrent - tasks.size; + if (slots <= 0) { + schedulePoll(handler, c.pollIntervalMs); + return; + } + const workerId = token.runtimeId; + const body: Record = { + handler_name: handler, + worker_id: workerId, + limit: slots, + capabilities: capabilities(workerId), + }; + if (c.version) body.version = c.version; + pollInFlight.set(handler, true); + post("/workers/tasks/poll", body) + .then( + (res) => { + if (res.status === 401 || res.status === 403) { + failures.set(handler, (failures.get(handler) || 0) + 1); + log("warn", "poll unauthorized", { handler, status: res.status }); + onAuthError(); + return; + } + if (!ok(res.status)) { + failures.set(handler, (failures.get(handler) || 0) + 1); + log("warn", "poll failed", { handler, status: res.status }); + return; + } + failures.set(handler, 0); + const payload = res.body as + | WorkerTask[] + | { tasks?: WorkerTask[]; lease_secs?: number; heartbeat_interval_secs?: number; poll_after_ms?: number } + | null; + let list: WorkerTask[] = []; + let leaseSecs: number | null = null; + let heartbeatSecs: number | null = null; + if (Array.isArray(payload)) { + list = payload; + } else if (payload && typeof payload === "object") { + list = Array.isArray(payload.tasks) ? payload.tasks : []; + if (typeof payload.lease_secs === "number") leaseSecs = payload.lease_secs; + if (typeof payload.heartbeat_interval_secs === "number") heartbeatSecs = payload.heartbeat_interval_secs; + hints.set(handler, typeof payload.poll_after_ms === "number" ? payload.poll_after_ms : 0); + } + list.forEach((task) => claimed(task, workerId, leaseSecs, heartbeatSecs)); + }, + (err) => { + failures.set(handler, (failures.get(handler) || 0) + 1); + log("warn", "poll error", { handler, error: String(err && err.name ? err.name : "network") }); + }, + ) + .then(() => { + pollInFlight.set(handler, false); + if (canPoll()) schedulePoll(handler, nextDelay(handler)); + }); + } + + function claimed(task: WorkerTask, workerId: string, leaseSecs: number | null, heartbeatSecs: number | null): void { + if (!task || typeof task.id !== "string" || tasks.has(task.id)) return; + const c = cfg as EngineConfig; + if (!canPoll()) { + // Paused or stopped while the poll was in flight: hand the claim back untouched. + releaseUnstarted(task, workerId); + return; + } + const lease = + typeof task.lease_secs === "number" && task.lease_secs > 0 + ? task.lease_secs + : leaseSecs !== null && leaseSecs > 0 + ? leaseSecs + : c.defaultLeaseSecs; + let hbMs = Math.max(1000, Math.floor((lease * 1000) / 3)); + if (heartbeatSecs !== null && heartbeatSecs > 0) hbMs = Math.max(1000, Math.min(hbMs, heartbeatSecs * 1000)); + const entry: Entry = { task, workerId, hbMs, hbTimer: null, acking: false }; + tasks.set(task.id, entry); + scheduleHeartbeat(entry); + log("debug", "task claimed", { taskId: task.id, handler: task.handler_name, leaseSecs: lease }); + emit({ type: "task", task, workerId, leaseSecs: lease }); + } + + function releaseUnstarted(task: WorkerTask, workerId: string): void { + if (!token) return; + post( + taskPath(task.id, "release"), + { worker_id: workerId, claim_epoch: task.claim_epoch, started: false }, + true, + ).then( + (res) => log("debug", "unstarted claim released", { taskId: task.id, status: res.status }), + () => log("warn", "release failed", { taskId: task.id }), + ); + } + + // --------------------------------------------------------------------------- + // Heartbeats + // --------------------------------------------------------------------------- + + function scheduleHeartbeat(entry: Entry): void { + if (entry.hbTimer) clearTimeout(entry.hbTimer); + entry.hbTimer = setTimeout(() => { + entry.hbTimer = null; + heartbeat(entry.task.id, null); + }, entry.hbMs); + } + + function lose(id: string, reason: string): void { + const entry = tasks.get(id); + if (!entry) return; + if (entry.hbTimer) clearTimeout(entry.hbTimer); + tasks.delete(id); + log("info", "lease lost", { taskId: id, reason }); + emit({ type: "abort", taskId: id, reason }); + } + + function heartbeat(id: string, requestId: number | null): void { + const reply = (success: boolean, error?: string) => { + if (requestId !== null) { + emit({ type: "heartbeatResult", taskId: id, requestId, ok: success, error }); + } + }; + const entry = tasks.get(id); + if (!entry || entry.acking) { + reply(false, entry ? "acknowledging" : "lease_lost"); + return; + } + if (!tokenValid()) { + reply(false, "token_expired"); + return; + } + if (entry.hbTimer) clearTimeout(entry.hbTimer); + entry.hbTimer = null; + post(taskPath(id, "heartbeat"), { worker_id: entry.workerId, claim_epoch: entry.task.claim_epoch }).then( + (res) => { + if (tasks.get(id) !== entry) { + reply(false, "lease_lost"); + return; + } + if (res.status === 404 || res.status === 409 || res.status === 410) { + lose(id, "lease_lost"); + reply(false, "lease_lost"); + return; + } + if (!entry.acking) scheduleHeartbeat(entry); + if (res.status === 401 || res.status === 403) { + onAuthError(); + reply(false, "unauthorized"); + return; + } + if (!ok(res.status)) { + log("warn", "heartbeat failed", { taskId: id, status: res.status }); + reply(false, "http_" + res.status); + return; + } + reply(true); + }, + () => { + if (tasks.get(id) === entry && !entry.acking) scheduleHeartbeat(entry); + log("warn", "heartbeat error", { taskId: id }); + reply(false, "network"); + }, + ); + } + + // --------------------------------------------------------------------------- + // Acknowledgement + // --------------------------------------------------------------------------- + + function acknowledge(command: Extract): void { + const entry = tasks.get(command.taskId); + if (!entry || entry.acking) { + emit({ type: "settled", taskId: command.taskId, outcome: "dropped" }); + return; + } + entry.acking = true; + if (entry.hbTimer) clearTimeout(entry.hbTimer); + entry.hbTimer = null; + let body: Record = command.ok + ? { worker_id: entry.workerId, claim_epoch: entry.task.claim_epoch, output: command.output === undefined ? {} : command.output } + : { worker_id: entry.workerId, claim_epoch: entry.task.claim_epoch, message: command.message, retryable: command.retryable }; + let path = taskPath(command.taskId, command.ok ? "complete" : "fail"); + let outcome: "completed" | "failed" = command.ok ? "completed" : "failed"; + const attempt = (n: number): void => { + if (tasks.get(command.taskId) !== entry) return; + if (!tokenValid()) { + finish("error"); + return; + } + post(path, body).then( + (res) => { + if (ok(res.status)) finish(outcome, res.status); + else if (res.status === 413 && outcome === "completed") { + // The server refused the output as too large: retrying the same + // payload can never succeed, so fail the step permanently now + // instead of holding the claim until the lease expires. + // Error envelope: {"error": {"code", "message", ...}}. + const envelope = res.body && typeof res.body === "object" ? (res.body as { error?: { message?: unknown } | unknown }) : null; + const inner = envelope && envelope.error && typeof envelope.error === "object" ? (envelope.error as { message?: unknown }) : null; + const reason = inner && typeof inner.message === "string" ? inner.message : ""; + body = { + worker_id: entry.workerId, + claim_epoch: entry.task.claim_epoch, + message: "step output was refused by the engine as too large (413)" + (reason ? ": " + reason.slice(0, 300) : ""), + retryable: false, + }; + path = taskPath(command.taskId, "fail"); + outcome = "failed"; + attempt(n); + } + else if (res.status === 404 || res.status === 409 || res.status === 410) finish("lost", res.status); + else if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500) { + if (res.status === 401 || res.status === 403) onAuthError(); + retry(n, res.status); + } else finish("error", res.status); + }, + () => retry(n), + ); + }; + const retry = (n: number, status?: number): void => { + if (n >= 3) { + finish("error", status); + return; + } + setTimeout(() => attempt(n + 1), 1000 * Math.pow(2, n)); + }; + const finish = (result: "completed" | "failed" | "lost" | "error", status?: number): void => { + if (tasks.get(command.taskId) === entry) tasks.delete(command.taskId); + log(result === "error" ? "warn" : "debug", "task settled", { taskId: command.taskId, outcome: result, status: status === undefined ? null : status }); + emit({ type: "settled", taskId: command.taskId, outcome: result, status }); + }; + attempt(0); + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + function clearPollTimers(): void { + pollTimers.forEach((timer) => clearTimeout(timer)); + pollTimers.clear(); + } + + function release(taskIds: string[], pause: boolean): void { + taskIds.forEach((id) => { + const entry = tasks.get(id); + if (!entry) return; + if (entry.hbTimer) clearTimeout(entry.hbTimer); + tasks.delete(id); + }); + if (pause) { + paused = true; + clearPollTimers(); + // Claims the facade never saw were never started: give them back. + tasks.forEach((entry, id) => { + if (entry.acking) return; + if (entry.hbTimer) clearTimeout(entry.hbTimer); + tasks.delete(id); + emit({ type: "abort", taskId: id, reason: "released" }); + releaseUnstarted(entry.task, entry.workerId); + }); + } + } + + function stop(reason: string): void { + if (stopped) return; + stopped = true; + running = false; + clearPollTimers(); + clearTokenTimers(); + tasks.forEach((entry, id) => { + if (entry.hbTimer) clearTimeout(entry.hbTimer); + emit({ type: "abort", taskId: id, reason }); + }); + tasks.clear(); + log("info", "engine stopped", { reason }); + emit({ type: "stopped", reason }); + } + + function handle(command: EngineCommand): void { + if (!command || typeof command !== "object") return; + switch (command.type) { + case "start": + if (running || stopped) return; + cfg = command.config; + running = true; + paused = false; + if (command.token) onToken(command.token); + else if (tokenValid()) ensurePolling(); + else requestToken(); + return; + case "token": + onToken(command); + return; + case "tokenError": + onTokenError(command.message); + return; + case "result": + acknowledge(command); + return; + case "heartbeat": + heartbeat(command.taskId, command.requestId); + return; + case "release": + release(command.taskIds, command.pause); + return; + case "resume": + if (!running || stopped) return; + paused = false; + if (tokenValid()) ensurePolling(); + else requestToken(); + return; + case "stop": + stop(command.reason); + return; + default: + return; + } + } + + return { handle }; +} + +/** + * Entry point evaluated inside the dedicated Web Worker. Bridges + * `postMessage` to the engine. Must stay self-contained (see file header). + */ +export function leaseWorkerBootstrap( + scope: { + postMessage(message: unknown): void; + addEventListener(type: "message", listener: (event: { data: unknown }) => void): void; + fetch(input: string, init: RequestInit): Promise; + }, + engineFactory: typeof leaseEngine, +): void { + const engine = engineFactory({ + emit: (event) => scope.postMessage(event), + fetch: (input, init) => scope.fetch(input, init), + }); + scope.addEventListener("message", (event) => engine.handle(event.data as EngineCommand)); + scope.postMessage({ type: "ready" }); +} diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 0000000..f17e167 --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,44 @@ +/** + * `@orch8.io/sdk/browser` — browser-safe entry point. + * + * Nothing in this module graph imports Node built-ins; `npm run check:browser` + * bundles it with esbuild for `platform: "browser"` and fails on any `node:` + * reference. Keep server-only code (API-key clients, webhook verification) + * out of this entry. + */ +export { + BrowserWorker, + LeaseLostError, + TaskReleasedError, + TaskTimeoutError, + type BrowserHandler, + type BrowserTaskContext, + type BrowserToken, + type BrowserWorkerEvent, + type BrowserWorkerMode, + type BrowserWorkerOptions, + type ReleaseTrigger, +} from "./worker.js"; +export { + MAX_OUTPUT_BYTES, + OutputTooLargeError, + assertOutputSize, + jsonByteLength, + readForm, + readSelection, + querySelectorText, + querySelectorAllText, + type FormValues, + type ReadFormOptions, +} from "./page-data.js"; +/** @internal Source of the dedicated lease worker; exported for the bundle self-test. */ +export { LEASE_WORKER_SOURCE as __leaseWorkerSource } from "./lease-worker-source.js"; +export type { + WorkerTask, + RuntimeKind, + RuntimePlacement, + RuntimeCapabilities, + RuntimeTrustLevel, + RuntimeConnectivity, + BrowserSession, +} from "../types.js"; diff --git a/src/browser/lease-worker-source.ts b/src/browser/lease-worker-source.ts new file mode 100644 index 0000000..95dece9 --- /dev/null +++ b/src/browser/lease-worker-source.ts @@ -0,0 +1,3 @@ +// GENERATED by scripts/generate-lease-worker.mjs from src/browser/engine.ts. Do not edit. +// Regenerate with `npm run generate:lease-worker`. +export const LEASE_WORKER_SOURCE = "\"use strict\";(()=>{function Q(m){let f=null,p=null,d=!1,w=!1,b=!1,T=new Map,S=new Map,h=new Map,z=new Map,l=new Map,M=null,x=null,y=null,A=!1,C=0;function W(){return m.now?m.now():Date.now()}function k(e){try{m.emit(e)}catch(t){}}function g(e,t,n){k({type:\"log\",level:e,message:t,fields:n||{}})}function _(){return p!==null&&p.expiresAt>W()}function R(e,t,n){if(!f||!p)return Promise.reject(new Error(\"engine has no credential\"));let r={method:\"POST\",headers:{\"Content-Type\":\"application/json\",Accept:\"application/json\",\"x-api-key\":p.token},body:JSON.stringify(t)};n&&(r.keepalive=!0);let i=null;if(typeof AbortController==\"function\"&&!n){let s=new AbortController;r.signal=s.signal,i=setTimeout(()=>s.abort(),f.requestTimeoutMs)}return m.fetch(f.baseUrl+e,r).then(s=>s.status===204?{status:204,body:null}:s.text().then(a=>{let u=null;if(a)try{u=JSON.parse(a)}catch(c){u=null}return{status:s.status,body:u}},()=>({status:s.status,body:null}))).then(s=>(i&&clearTimeout(i),s),s=>{throw i&&clearTimeout(i),s})}function j(e){return e>=200&&e<300}function P(e,t){return\"/workers/tasks/\"+encodeURIComponent(e)+\"/\"+t}function I(){b||A||(A=!0,k({type:\"needToken\"}))}function L(){M&&clearTimeout(M),x&&clearTimeout(x),y&&clearTimeout(y),M=null,x=null,y=null}function U(e){if(b)return;A=!1,C=0,L(),p={token:e.token,expiresAt:e.expiresAt,runtimeId:e.runtimeId};let t=p.expiresAt-W();if(t<=0){F();return}let n=Math.min(f?f.refreshMarginMs:6e4,t/2);M=setTimeout(I,Math.max(0,t-n)),x=setTimeout(F,t),q()}function Y(e){if(b)return;A=!1,C+=1,g(\"warn\",\"token refresh failed\",{failures:C,error:e.slice(0,200)});let t=Math.min(1e3*Math.pow(2,C-1),3e4);y&&clearTimeout(y),y=setTimeout(()=>{y=null,I()},t)}function F(){_()||G(\"token_expired\")}function O(){I()}function E(){return d&&!w&&!b&&_()}function q(){!f||!E()||f.handlers.forEach(e=>{!T.has(e)&&!S.get(e)&&H(e,0)})}function H(e,t){let n=T.get(e);n&&clearTimeout(n),T.delete(e),E()&&T.set(e,setTimeout(()=>{T.delete(e),ee(e)},t))}function Z(e){let t=f,n=h.get(e)||0;if(n>0){let r=Math.min(t.pollIntervalMs*Math.pow(2,n),t.maxBackoffMs);return Math.round(r/2+Math.random()*r/2)}return Math.max(t.pollIntervalMs,z.get(e)||0)}function $(e){let t=f,n=W(),r=Math.min(n+t.capabilityTtlSecs*1e3,p.expiresAt),i={runtime_id:e,kind:\"browser\",trust:t.trust,handlers:t.handlers.slice(),offline_capable:!1,observed_at:new Date(n).toISOString(),expires_at:new Date(r).toISOString()};return t.connectivity&&(i.connectivity=t.connectivity),i}function ee(e){if(!E()||!f||!p)return;let t=f,n=t.maxConcurrent-l.size;if(n<=0){H(e,t.pollIntervalMs);return}let r=p.runtimeId,i={handler_name:e,worker_id:r,limit:n,capabilities:$(r)};t.version&&(i.version=t.version),S.set(e,!0),R(\"/workers/tasks/poll\",i).then(s=>{if(s.status===401||s.status===403){h.set(e,(h.get(e)||0)+1),g(\"warn\",\"poll unauthorized\",{handler:e,status:s.status}),O();return}if(!j(s.status)){h.set(e,(h.get(e)||0)+1),g(\"warn\",\"poll failed\",{handler:e,status:s.status});return}h.set(e,0);let a=s.body,u=[],c=null,o=null;Array.isArray(a)?u=a:a&&typeof a==\"object\"&&(u=Array.isArray(a.tasks)?a.tasks:[],typeof a.lease_secs==\"number\"&&(c=a.lease_secs),typeof a.heartbeat_interval_secs==\"number\"&&(o=a.heartbeat_interval_secs),z.set(e,typeof a.poll_after_ms==\"number\"?a.poll_after_ms:0)),u.forEach(v=>te(v,r,c,o))},s=>{h.set(e,(h.get(e)||0)+1),g(\"warn\",\"poll error\",{handler:e,error:String(s&&s.name?s.name:\"network\")})}).then(()=>{S.set(e,!1),E()&&H(e,Z(e))})}function te(e,t,n,r){if(!e||typeof e.id!=\"string\"||l.has(e.id))return;let i=f;if(!E()){J(e,t);return}let s=typeof e.lease_secs==\"number\"&&e.lease_secs>0?e.lease_secs:n!==null&&n>0?n:i.defaultLeaseSecs,a=Math.max(1e3,Math.floor(s*1e3/3));r!==null&&r>0&&(a=Math.max(1e3,Math.min(a,r*1e3)));let u={task:e,workerId:t,hbMs:a,hbTimer:null,acking:!1};l.set(e.id,u),B(u),g(\"debug\",\"task claimed\",{taskId:e.id,handler:e.handler_name,leaseSecs:s}),k({type:\"task\",task:e,workerId:t,leaseSecs:s})}function J(e,t){p&&R(P(e.id,\"release\"),{worker_id:t,claim_epoch:e.claim_epoch,started:!1},!0).then(n=>g(\"debug\",\"unstarted claim released\",{taskId:e.id,status:n.status}),()=>g(\"warn\",\"release failed\",{taskId:e.id}))}function B(e){e.hbTimer&&clearTimeout(e.hbTimer),e.hbTimer=setTimeout(()=>{e.hbTimer=null,N(e.task.id,null)},e.hbMs)}function ne(e,t){let n=l.get(e);n&&(n.hbTimer&&clearTimeout(n.hbTimer),l.delete(e),g(\"info\",\"lease lost\",{taskId:e,reason:t}),k({type:\"abort\",taskId:e,reason:t}))}function N(e,t){let n=(i,s)=>{t!==null&&k({type:\"heartbeatResult\",taskId:e,requestId:t,ok:i,error:s})},r=l.get(e);if(!r||r.acking){n(!1,r?\"acknowledging\":\"lease_lost\");return}if(!_()){n(!1,\"token_expired\");return}r.hbTimer&&clearTimeout(r.hbTimer),r.hbTimer=null,R(P(e,\"heartbeat\"),{worker_id:r.workerId,claim_epoch:r.task.claim_epoch}).then(i=>{if(l.get(e)!==r){n(!1,\"lease_lost\");return}if(i.status===404||i.status===409||i.status===410){ne(e,\"lease_lost\"),n(!1,\"lease_lost\");return}if(r.acking||B(r),i.status===401||i.status===403){O(),n(!1,\"unauthorized\");return}if(!j(i.status)){g(\"warn\",\"heartbeat failed\",{taskId:e,status:i.status}),n(!1,\"http_\"+i.status);return}n(!0)},()=>{l.get(e)===r&&!r.acking&&B(r),g(\"warn\",\"heartbeat error\",{taskId:e}),n(!1,\"network\")})}function re(e){let t=l.get(e.taskId);if(!t||t.acking){k({type:\"settled\",taskId:e.taskId,outcome:\"dropped\"});return}t.acking=!0,t.hbTimer&&clearTimeout(t.hbTimer),t.hbTimer=null;let n=e.ok?{worker_id:t.workerId,claim_epoch:t.task.claim_epoch,output:e.output===void 0?{}:e.output}:{worker_id:t.workerId,claim_epoch:t.task.claim_epoch,message:e.message,retryable:e.retryable},r=P(e.taskId,e.ok?\"complete\":\"fail\"),i=e.ok?\"completed\":\"failed\",s=c=>{if(l.get(e.taskId)===t){if(!_()){u(\"error\");return}R(r,n).then(o=>{if(j(o.status))u(i,o.status);else if(o.status===413&&i===\"completed\"){let v=o.body&&typeof o.body==\"object\"?o.body:null,D=v&&v.error&&typeof v.error==\"object\"?v.error:null,K=D&&typeof D.message==\"string\"?D.message:\"\";n={worker_id:t.workerId,claim_epoch:t.task.claim_epoch,message:\"step output was refused by the engine as too large (413)\"+(K?\": \"+K.slice(0,300):\"\"),retryable:!1},r=P(e.taskId,\"fail\"),i=\"failed\",s(c)}else o.status===404||o.status===409||o.status===410?u(\"lost\",o.status):o.status===401||o.status===403||o.status===429||o.status>=500?((o.status===401||o.status===403)&&O(),a(c,o.status)):u(\"error\",o.status)},()=>a(c))}},a=(c,o)=>{if(c>=3){u(\"error\",o);return}setTimeout(()=>s(c+1),1e3*Math.pow(2,c))},u=(c,o)=>{l.get(e.taskId)===t&&l.delete(e.taskId),g(c===\"error\"?\"warn\":\"debug\",\"task settled\",{taskId:e.taskId,outcome:c,status:o===void 0?null:o}),k({type:\"settled\",taskId:e.taskId,outcome:c,status:o})};s(0)}function V(){T.forEach(e=>clearTimeout(e)),T.clear()}function se(e,t){e.forEach(n=>{let r=l.get(n);r&&(r.hbTimer&&clearTimeout(r.hbTimer),l.delete(n))}),t&&(w=!0,V(),l.forEach((n,r)=>{n.acking||(n.hbTimer&&clearTimeout(n.hbTimer),l.delete(r),k({type:\"abort\",taskId:r,reason:\"released\"}),J(n.task,n.workerId))}))}function G(e){b||(b=!0,d=!1,V(),L(),l.forEach((t,n)=>{t.hbTimer&&clearTimeout(t.hbTimer),k({type:\"abort\",taskId:n,reason:e})}),l.clear(),g(\"info\",\"engine stopped\",{reason:e}),k({type:\"stopped\",reason:e}))}function ie(e){if(!(!e||typeof e!=\"object\"))switch(e.type){case\"start\":if(d||b)return;f=e.config,d=!0,w=!1,e.token?U(e.token):_()?q():I();return;case\"token\":U(e);return;case\"tokenError\":Y(e.message);return;case\"result\":re(e);return;case\"heartbeat\":N(e.taskId,e.requestId);return;case\"release\":se(e.taskIds,e.pause);return;case\"resume\":if(!d||b)return;w=!1,_()?q():I();return;case\"stop\":G(e.reason);return;default:return}}return{handle:ie}}function X(m,f){let p=f({emit:d=>m.postMessage(d),fetch:(d,w)=>m.fetch(d,w)});m.addEventListener(\"message\",d=>p.handle(d.data)),m.postMessage({type:\"ready\"})}X(self,Q);})();"; diff --git a/src/browser/page-data.ts b/src/browser/page-data.ts new file mode 100644 index 0000000..ab0e95a --- /dev/null +++ b/src/browser/page-data.ts @@ -0,0 +1,98 @@ +/** + * Small, optional helpers for reading data from the current page inside a + * browser step handler. Page data is untrusted input: return it as step + * output and validate it downstream. Password fields are never read. + */ + +/** Server default for the maximum accepted step output size (1 MiB). */ +export const MAX_OUTPUT_BYTES = 1024 * 1024; + +export class OutputTooLargeError extends Error { + readonly retryable = false; + constructor( + readonly bytes: number, + readonly limit: number, + ) { + super( + `step output is ${bytes} bytes of JSON, which exceeds the ${limit}-byte limit; ` + + "return a smaller result (e.g. selected fields or a summary) instead of raw page content", + ); + this.name = "OutputTooLargeError"; + } +} + +/** UTF-8 byte length of `value` serialized as JSON (`undefined` counts as `{}`). */ +export function jsonByteLength(value: unknown): number { + const text = JSON.stringify(value === undefined ? {} : value); + return new TextEncoder().encode(text ?? "null").length; +} + +/** Throw `OutputTooLargeError` when `value` serializes to more than `limit` bytes. */ +export function assertOutputSize(value: unknown, limit: number = MAX_OUTPUT_BYTES): void { + const bytes = jsonByteLength(value); + if (bytes > limit) throw new OutputTooLargeError(bytes, limit); +} + +type Root = ParentNode; + +function resolveElement(target: string | T, root: Root = document): T | null { + return typeof target === "string" ? root.querySelector(target) : target; +} + +export interface ReadFormOptions { + /** Include `` values (often CSRF tokens). Default: false. */ + includeHidden?: boolean; + root?: Root; +} + +export type FormValues = Record; + +/** + * Read a form's current values as plain JSON. Repeated names (checkbox groups, + * multi-selects) become arrays. Password and file inputs are always skipped, + * hidden inputs unless `includeHidden` is set. + */ +export function readForm(target: string | HTMLFormElement, options: ReadFormOptions = {}): FormValues { + const form = resolveElement(target, options.root); + if (!form) throw new Error(`readForm: no form matches ${typeof target === "string" ? target : "the element"}`); + const values: FormValues = {}; + const add = (name: string, value: string) => { + const existing = values[name]; + if (existing === undefined) values[name] = value; + else if (Array.isArray(existing)) existing.push(value); + else values[name] = [existing, value]; + }; + for (const element of Array.from(form.elements)) { + const field = element as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + if (!field.name || field.disabled) continue; + if (field instanceof HTMLInputElement) { + const type = field.type.toLowerCase(); + if (type === "password" || type === "file") continue; + if (type === "hidden" && !options.includeHidden) continue; + if (type === "submit" || type === "button" || type === "reset" || type === "image") continue; + if ((type === "checkbox" || type === "radio") && !field.checked) continue; + add(field.name, field.value); + } else if (field instanceof HTMLSelectElement) { + for (const option of Array.from(field.selectedOptions)) add(field.name, option.value); + } else if (field instanceof HTMLTextAreaElement) { + add(field.name, field.value); + } + } + return values; +} + +/** The user's current text selection, or `""`. */ +export function readSelection(): string { + return typeof getSelection === "function" ? (getSelection()?.toString() ?? "") : ""; +} + +/** Trimmed text content of the first element matching `selector`, or `null`. */ +export function querySelectorText(selector: string, root: Root = document): string | null { + const element = root.querySelector(selector); + return element ? (element.textContent ?? "").trim() : null; +} + +/** Trimmed text content of every element matching `selector`. */ +export function querySelectorAllText(selector: string, root: Root = document): string[] { + return Array.from(root.querySelectorAll(selector), (element) => (element.textContent ?? "").trim()); +} diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts new file mode 100644 index 0000000..3074a3e --- /dev/null +++ b/src/browser/protocol.ts @@ -0,0 +1,64 @@ +/** + * Message protocol between the `BrowserWorker` facade (main thread, runs + * handlers with DOM access) and the lease engine (poll/heartbeat/ack loop, + * running in a dedicated Web Worker or on the main thread as a fallback). + * + * Everything here crosses `postMessage`, so it must stay structured-clone safe. + */ +import type { WorkerTask } from "../types.js"; + +export interface EngineConfig { + baseUrl: string; + handlers: string[]; + maxConcurrent: number; + pollIntervalMs: number; + maxBackoffMs: number; + /** Lifetime of each poll's capability advertisement (server max 300). */ + capabilityTtlSecs: number; + trust: "unverified" | "registered"; + connectivity?: "offline" | "metered" | "wifi" | "ethernet"; + /** Refresh the token this long before it expires. */ + refreshMarginMs: number; + /** Lease assumed when the server does not advertise one. */ + defaultLeaseSecs: number; + requestTimeoutMs: number; + version?: string; +} + +export type EngineCommand = + | { type: "start"; config: EngineConfig; token?: { token: string; expiresAt: number; runtimeId: string } } + | { type: "token"; token: string; expiresAt: number; runtimeId: string } + | { type: "tokenError"; message: string } + | { type: "result"; taskId: string; ok: true; output: unknown } + | { type: "result"; taskId: string; ok: false; message: string; retryable: boolean } + | { type: "heartbeat"; taskId: string; requestId: number } + /** + * The facade released `taskIds` itself (keepalive fetch). The engine + * forgets them, releases any claim the facade never saw (`started: false`) + * and, with `pause`, stops polling until `resume`. + */ + | { type: "release"; taskIds: string[]; pause: boolean } + | { type: "resume" } + | { type: "stop"; reason: string }; + +export type EngineTaskOutcome = "completed" | "failed" | "lost" | "dropped" | "error"; + +export type EngineEvent = + | { type: "ready" } + | { type: "needToken" } + | { type: "task"; task: WorkerTask; workerId: string; leaseSecs: number } + | { type: "abort"; taskId: string; reason: string } + | { type: "heartbeatResult"; taskId: string; requestId: number; ok: boolean; error?: string } + | { type: "settled"; taskId: string; outcome: EngineTaskOutcome; status?: number } + | { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string; fields: Record } + | { type: "stopped"; reason: string }; + +export interface EngineHost { + emit(event: EngineEvent): void; + fetch(input: string, init: RequestInit): Promise; + now?(): number; +} + +export interface EngineController { + handle(command: EngineCommand): void; +} diff --git a/src/browser/worker.ts b/src/browser/worker.ts new file mode 100644 index 0000000..e24aae1 --- /dev/null +++ b/src/browser/worker.ts @@ -0,0 +1,606 @@ +import type { WorkerTask } from "../types.js"; +import { leaseEngine } from "./engine.js"; +import { LEASE_WORKER_SOURCE } from "./lease-worker-source.js"; +import type { EngineCommand, EngineConfig, EngineEvent } from "./protocol.js"; +import { MAX_OUTPUT_BYTES, OutputTooLargeError, jsonByteLength } from "./page-data.js"; + +/** What `getToken` resolves to. Snake-case fields from the raw session response are accepted too. */ +export interface BrowserToken { + token: string; + /** Expiry as an RFC 3339 string, a `Date`, or epoch milliseconds. */ + expiresAt: string | number | Date; + runtimeId: string; +} + +type RawToken = + | BrowserToken + | { token: string; expires_at: string | number; runtime_id: string; [key: string]: unknown }; + +export interface BrowserTaskContext { + taskId: string; + instanceId: string; + blockId: string; + handlerName: string; + attempt: number; + /** Deterministic idempotency key for the step's effect; pass it to downstream APIs. `null` on older servers. */ + effectId: string | null; + /** Filtered instance context (the server never sends credentials to browser runtimes). */ + context: unknown; + /** Aborted on lease loss, release (tab hidden/closed), timeout, or `stop()`. */ + signal: AbortSignal; + /** Extend the lease now. Rejects with `LeaseLostError` if the lease is gone. */ + heartbeat(): Promise; +} + +export type BrowserHandler = (input: I, ctx: BrowserTaskContext) => O | Promise; + +export type ReleaseTrigger = "pagehide" | "hidden"; + +export type BrowserWorkerMode = "auto" | "worker" | "main"; + +/** Lifecycle events. They carry ids and counts only, never task input or output. */ +export type BrowserWorkerEvent = + | { type: "started"; runtimeId: string; mode: "worker" | "main" } + | { type: "task_started"; taskId: string; handler: string } + | { type: "task_completed"; taskId: string } + | { type: "task_failed"; taskId: string; retryable: boolean } + | { type: "task_aborted"; taskId: string; reason: string } + | { type: "ack_failed"; taskId: string; outcome: "lost" | "error" } + | { type: "released"; taskIds: string[]; reason: string } + | { type: "token_refreshed"; expiresAt: number } + | { type: "error"; message: string } + | { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string; fields: Record } + | { type: "stopped"; reason: string }; + +export interface BrowserWorkerOptions { + /** Base URL of the Orch8 engine API; must allow this origin via `ORCH8_CORS_ORIGINS`. */ + baseUrl: string; + /** + * Fetch a browser session token from YOUR backend, which mints it with + * `client.createBrowserSession()`. Called on start and before expiry. + */ + getToken: () => Promise; + /** `auto` (default) runs the lease loop in a dedicated Web Worker when possible, else on the main thread. */ + mode?: BrowserWorkerMode; + /** Page lifecycle events that release in-flight tasks and pause polling. Default: both. */ + releaseOn?: ReleaseTrigger[]; + /** Concurrent tasks. Default: 1 (browser steps usually involve the user). */ + maxConcurrent?: number; + /** Idle poll interval (ms). Default: 1000. */ + pollIntervalMs?: number; + /** Upper bound of the exponential poll backoff (ms). Default: 30000. */ + maxBackoffMs?: number; + /** Maximum JSON size of a handler's output. Default: 1 MiB (the server default). */ + maxOutputBytes?: number; + /** Refresh the token this many seconds before it expires. Default: 60. */ + refreshMarginSecs?: number; + /** Lifetime of the capability advertisement sent with each poll, 1..300 s. Default: 240. */ + capabilityTtlSecs?: number; + /** Lease assumed when the server does not advertise `lease_secs`. Default: 30. */ + defaultLeaseSecs?: number; + /** Per-request timeout (ms). Default: 30000. */ + requestTimeoutMs?: number; + /** Advertised connectivity; derived from `navigator.connection` when omitted. */ + connectivity?: "metered" | "wifi" | "ethernet"; + /** Advertised trust; the token binds the identity, so `registered` is the default. */ + trust?: "unverified" | "registered"; + /** App build version recorded in the worker registry. */ + version?: string; + /** Lifecycle observer; receives ids only, never payloads. */ + onEvent?: (event: BrowserWorkerEvent) => void; + /** Fetch override (main-thread engine and release requests). */ + fetch?: typeof fetch; +} + +export class LeaseLostError extends Error { + constructor(readonly reason: string) { + super(`task lease is no longer held (${reason})`); + this.name = "LeaseLostError"; + } +} + +export class TaskReleasedError extends Error { + constructor(readonly reason: string) { + super(`task was released (${reason})`); + this.name = "TaskReleasedError"; + } +} + +export class TaskTimeoutError extends Error { + constructor(readonly timeoutMs: number) { + super(`task timed out after ${timeoutMs} ms`); + this.name = "TaskTimeoutError"; + } +} + +interface EngineLink { + readonly mode: "worker" | "main"; + send(command: EngineCommand): void; + close(): void; +} + +interface InFlight { + task: WorkerTask; + workerId: string; + controller: AbortController; + started: boolean; + timeout: ReturnType | null; +} + +type State = "idle" | "starting" | "running" | "stopped"; + +const WORKER_READY_TIMEOUT_MS = 3000; + +function normalizeToken(raw: RawToken): { token: string; expiresAt: number; runtimeId: string } { + const record = raw as Record; + const token = record.token; + const runtimeId = record.runtimeId ?? record.runtime_id; + const expires = record.expiresAt ?? record.expires_at; + if (typeof token !== "string" || token.length === 0) throw new TypeError("getToken() returned no token"); + if (typeof runtimeId !== "string" || runtimeId.length === 0) throw new TypeError("getToken() returned no runtimeId"); + let expiresAt: number; + if (expires instanceof Date) expiresAt = expires.getTime(); + else if (typeof expires === "number") expiresAt = expires < 1e12 ? expires * 1000 : expires; + else if (typeof expires === "string") expiresAt = Date.parse(expires); + else expiresAt = Number.NaN; + if (!Number.isFinite(expiresAt)) throw new TypeError("getToken() returned an invalid expiresAt"); + return { token, expiresAt, runtimeId }; +} + +function detectConnectivity(): EngineConfig["connectivity"] { + const nav = (globalThis as { navigator?: Navigator & { connection?: { type?: string; saveData?: boolean } } }).navigator; + if (!nav) return undefined; + if (nav.onLine === false) return "offline"; + const connection = nav.connection; + if (connection?.type === "ethernet") return "ethernet"; + if (connection?.type === "cellular" || connection?.saveData) return "metered"; + // Browsers without the Network Information API: an online browser has a network path. + return "wifi"; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Runs Orch8 step handlers inside a web page. Handlers execute on the main + * thread (they may read the DOM and ask the user), while polling, + * heartbeating and acknowledgement run in a dedicated Web Worker whose timers + * are throttled far less than a background tab's. + * + * The browser authenticates with a short-lived token minted by your backend, + * never with an API key, and never receives secrets. + */ +export class BrowserWorker { + private readonly handlers = new Map(); + private readonly inflight = new Map(); + private readonly heartbeatWaiters = new Map void; reject: (err: Error) => void }>(); + private readonly baseUrl: string; + private readonly releaseOn: ReleaseTrigger[]; + private readonly maxOutputBytes: number; + private link: EngineLink | null = null; + private session: { token: string; expiresAt: number; runtimeId: string } | null = null; + private state: State = "idle"; + private paused = false; + private nextRequestId = 1; + private tokenFetch: Promise | null = null; + private detach: (() => void) | null = null; + + constructor(private readonly options: BrowserWorkerOptions) { + if (!options.baseUrl) throw new TypeError("BrowserWorker requires baseUrl"); + if (typeof options.getToken !== "function") throw new TypeError("BrowserWorker requires getToken"); + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.releaseOn = options.releaseOn ?? ["pagehide", "hidden"]; + this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES; + } + + /** Register a handler. Call before `start()`; the handler list is advertised in capabilities. */ + register(handlerName: string, handler: BrowserHandler): this { + if (this.state !== "idle" && this.state !== "stopped") { + throw new Error("register() must be called before start()"); + } + if (!handlerName) throw new TypeError("handler name is required"); + this.handlers.set(handlerName, handler as BrowserHandler); + return this; + } + + /** Current mode once started (`worker` or `main`), else `null`. */ + get mode(): "worker" | "main" | null { + return this.link?.mode ?? null; + } + + get running(): boolean { + return this.state === "running"; + } + + /** Fetch the first token, start the lease loop and attach page lifecycle listeners. */ + async start(): Promise { + if (this.state === "running" || this.state === "starting") return; + if (this.handlers.size === 0) throw new Error("register at least one handler before start()"); + this.state = "starting"; + this.paused = false; + try { + this.session = normalizeToken(await this.options.getToken()); + this.link = await this.openLink(this.options.mode ?? "auto"); + } catch (err) { + this.state = "idle"; + throw err; + } + this.state = "running"; + this.link.send({ type: "start", config: this.engineConfig(), token: this.session }); + this.attachLifecycle(); + this.notify({ type: "started", runtimeId: this.session.runtimeId, mode: this.link.mode }); + } + + /** Release in-flight tasks, stop polling and detach listeners. */ + async stop(reason = "stopped"): Promise { + if (this.state !== "running") return; + const releasing = this.release(reason); + this.shutdown(reason); + await releasing; + } + + /** + * Give every in-flight task back to the engine (with `fetch(..., {keepalive: true})`, + * so it survives page unload), abort the handlers' signals, and pause polling + * until `resume()`. Called automatically on `pagehide` / `visibilitychange`. + */ + release(reason = "manual"): Promise { + const session = this.session; + const fetchImpl = this.fetchImpl(); + const pending: Promise[] = []; + const ids: string[] = []; + for (const [id, entry] of this.inflight) { + ids.push(id); + if (session && fetchImpl) { + pending.push( + fetchImpl(`${this.baseUrl}/workers/tasks/${encodeURIComponent(id)}/release`, { + method: "POST", + keepalive: true, + headers: { "Content-Type": "application/json", "x-api-key": session.token }, + body: JSON.stringify({ worker_id: entry.workerId, claim_epoch: entry.task.claim_epoch, started: entry.started }), + }).catch(() => undefined), + ); + } + this.settle(id); + entry.controller.abort(new TaskReleasedError(reason)); + } + this.paused = true; + this.link?.send({ type: "release", taskIds: ids, pause: true }); + if (ids.length > 0) this.notify({ type: "released", taskIds: ids, reason }); + return Promise.all(pending).then(() => undefined); + } + + /** Resume polling after `release()` (done automatically when the page becomes visible again). */ + resume(): void { + if (this.state !== "running" || !this.paused) return; + this.paused = false; + this.link?.send({ type: "resume" }); + } + + // --------------------------------------------------------------------------- + + private engineConfig(): EngineConfig { + const o = this.options; + const ttl = Math.min(Math.max(o.capabilityTtlSecs ?? 240, 1), 300); + return { + baseUrl: this.baseUrl, + handlers: Array.from(this.handlers.keys()), + maxConcurrent: Math.max(1, o.maxConcurrent ?? 1), + pollIntervalMs: o.pollIntervalMs ?? 1000, + maxBackoffMs: o.maxBackoffMs ?? 30_000, + capabilityTtlSecs: ttl, + trust: o.trust ?? "registered", + connectivity: o.connectivity ?? detectConnectivity(), + refreshMarginMs: (o.refreshMarginSecs ?? 60) * 1000, + defaultLeaseSecs: o.defaultLeaseSecs ?? 30, + requestTimeoutMs: o.requestTimeoutMs ?? 30_000, + version: o.version, + }; + } + + private fetchImpl(): typeof fetch | undefined { + if (this.options.fetch) return this.options.fetch; + return typeof fetch === "function" ? fetch.bind(globalThis) : undefined; + } + + private async openLink(mode: BrowserWorkerMode): Promise { + if (mode !== "main") { + const link = await this.openWorkerLink(); + if (link) return link; + if (mode === "worker") throw new Error("dedicated Web Workers are unavailable (unsupported or blocked by CSP worker-src)"); + } + return this.openMainLink(); + } + + private openMainLink(): EngineLink { + const fetchImpl = this.fetchImpl(); + if (!fetchImpl) throw new Error("fetch is not available"); + let closed = false; + const engine = leaseEngine({ + emit: (event) => { + // Deliver asynchronously, like postMessage, so engine state settles first. + void Promise.resolve().then(() => { + if (!closed) this.onEngineEvent(event); + }); + }, + fetch: (input, init) => fetchImpl(input, init), + }); + return { + mode: "main", + send: (command) => { + if (!closed) engine.handle(command); + }, + close: () => { + closed = true; + }, + }; + } + + private openWorkerLink(): Promise { + const g = globalThis as { Worker?: typeof Worker; Blob?: typeof Blob; URL?: typeof URL }; + if (typeof g.Worker !== "function" || typeof g.Blob !== "function" || typeof g.URL?.createObjectURL !== "function") { + return Promise.resolve(null); + } + let url: string; + let worker: Worker; + try { + url = URL.createObjectURL(new Blob([LEASE_WORKER_SOURCE], { type: "text/javascript" })); + worker = new Worker(url, { name: "orch8-lease" }); + } catch { + return Promise.resolve(null); + } + return new Promise((resolve) => { + let ready = false; + const cleanupUrl = () => URL.revokeObjectURL(url); + const fail = () => { + if (ready) return; + ready = true; + clearTimeout(timer); + cleanupUrl(); + worker.terminate(); + resolve(null); + }; + const timer = setTimeout(fail, WORKER_READY_TIMEOUT_MS); + worker.addEventListener("error", (event) => { + if (!ready) { + event.preventDefault(); + fail(); + return; + } + // A crash after startup: release what the page holds and continue on the main thread. + this.recoverFromWorkerCrash(); + }); + worker.addEventListener("message", (event: MessageEvent) => { + if (!ready && event.data?.type === "ready") { + ready = true; + clearTimeout(timer); + cleanupUrl(); + resolve({ + mode: "worker", + send: (command) => worker.postMessage(command), + close: () => worker.terminate(), + }); + return; + } + if (ready && this.link?.mode === "worker") this.onEngineEvent(event.data); + }); + }); + } + + private recoverFromWorkerCrash(): void { + if (this.state !== "running" || this.link?.mode !== "worker") return; + this.notify({ type: "error", message: "lease worker crashed; continuing on the main thread" }); + void this.release("engine_error"); + this.link.close(); + this.link = this.openMainLink(); + this.paused = false; + this.link.send({ type: "start", config: this.engineConfig(), token: this.session ?? undefined }); + } + + private onEngineEvent(event: EngineEvent): void { + switch (event.type) { + case "needToken": + this.refreshToken(); + return; + case "task": + this.runTask(event.task, event.workerId); + return; + case "abort": { + const entry = this.inflight.get(event.taskId); + if (entry) { + this.settle(event.taskId); + entry.controller.abort(new LeaseLostError(event.reason)); + } + this.notify({ type: "task_aborted", taskId: event.taskId, reason: event.reason }); + return; + } + case "heartbeatResult": { + const waiter = this.heartbeatWaiters.get(event.requestId); + if (!waiter) return; + this.heartbeatWaiters.delete(event.requestId); + if (event.ok) waiter.resolve(); + else if (event.error === "lease_lost") waiter.reject(new LeaseLostError("lease_lost")); + else waiter.reject(new Error(`heartbeat failed: ${event.error ?? "unknown"}`)); + return; + } + case "settled": + if (event.outcome === "lost" || event.outcome === "error") { + this.notify({ type: "ack_failed", taskId: event.taskId, outcome: event.outcome }); + } + return; + case "log": + this.notify(event); + return; + case "stopped": + this.shutdown(event.reason); + return; + default: + return; + } + } + + private refreshToken(): void { + if (this.tokenFetch || this.state !== "running") return; + this.tokenFetch = Promise.resolve() + .then(() => this.options.getToken()) + .then( + (raw) => { + const next = normalizeToken(raw); + this.session = next; + this.link?.send({ type: "token", ...next }); + this.notify({ type: "token_refreshed", expiresAt: next.expiresAt }); + }, + (err: unknown) => { + const message = errorMessage(err); + this.link?.send({ type: "tokenError", message }); + this.notify({ type: "error", message: `token refresh failed: ${message}` }); + }, + ) + .finally(() => { + this.tokenFetch = null; + }); + } + + private runTask(task: WorkerTask, workerId: string): void { + const handler = this.handlers.get(task.handler_name); + if (!handler) { + this.link?.send({ type: "result", taskId: task.id, ok: false, message: `no handler registered for "${task.handler_name}"`, retryable: false }); + return; + } + const controller = new AbortController(); + const entry: InFlight = { task, workerId, controller, started: false, timeout: null }; + this.inflight.set(task.id, entry); + if (task.timeout_ms && task.timeout_ms > 0) { + const timeoutMs = task.timeout_ms; + entry.timeout = setTimeout(() => { + if (this.inflight.get(task.id) !== entry) return; + this.settle(task.id); + controller.abort(new TaskTimeoutError(timeoutMs)); + this.link?.send({ type: "result", taskId: task.id, ok: false, message: "task timed out", retryable: true }); + this.notify({ type: "task_failed", taskId: task.id, retryable: true }); + }, timeoutMs); + } + const ctx: BrowserTaskContext = { + taskId: task.id, + instanceId: task.instance_id, + blockId: task.block_id, + handlerName: task.handler_name, + attempt: task.attempt, + effectId: task.effect_id ?? null, + context: task.context, + signal: controller.signal, + heartbeat: () => this.heartbeat(task.id), + }; + this.notify({ type: "task_started", taskId: task.id, handler: task.handler_name }); + const notRun = Symbol("notRun"); + Promise.resolve() + .then(() => { + // Released (tab hidden/closed), timed out or stopped before the + // handler ran: never run it, so the release reported `started: false` + // and the engine hands the task straight back to `pending`. + if (this.inflight.get(task.id) !== entry) return notRun; + entry.started = true; + return handler(task.params, ctx); + }) + .then( + (output) => { + if (output === notRun || this.inflight.get(task.id) !== entry) return; + this.settle(task.id); + let bytes: number; + try { + bytes = jsonByteLength(output); + } catch (err) { + this.fail(task.id, `step output is not JSON-serializable: ${errorMessage(err)}`, false); + return; + } + if (bytes > this.maxOutputBytes) { + this.fail(task.id, new OutputTooLargeError(bytes, this.maxOutputBytes).message, false); + return; + } + this.link?.send({ type: "result", taskId: task.id, ok: true, output: output === undefined ? {} : output }); + this.notify({ type: "task_completed", taskId: task.id }); + }, + (err: unknown) => { + if (this.inflight.get(task.id) !== entry) return; + this.settle(task.id); + const retryable = err instanceof Error && "retryable" in err ? Boolean((err as { retryable: unknown }).retryable) : true; + this.fail(task.id, errorMessage(err), retryable); + }, + ); + } + + private fail(taskId: string, message: string, retryable: boolean): void { + this.link?.send({ type: "result", taskId, ok: false, message, retryable }); + this.notify({ type: "task_failed", taskId, retryable }); + } + + private settle(taskId: string): void { + const entry = this.inflight.get(taskId); + if (!entry) return; + if (entry.timeout) clearTimeout(entry.timeout); + this.inflight.delete(taskId); + } + + private heartbeat(taskId: string): Promise { + if (!this.inflight.has(taskId) || !this.link) return Promise.reject(new LeaseLostError("lease_lost")); + const requestId = this.nextRequestId++; + return new Promise((resolve, reject) => { + this.heartbeatWaiters.set(requestId, { resolve, reject }); + this.link?.send({ type: "heartbeat", taskId, requestId }); + }); + } + + private shutdown(reason: string): void { + if (this.state === "stopped" || this.state === "idle") return; + this.state = "stopped"; + for (const [id, entry] of this.inflight) { + this.settle(id); + entry.controller.abort(new TaskReleasedError(reason)); + } + for (const waiter of this.heartbeatWaiters.values()) waiter.reject(new LeaseLostError(reason)); + this.heartbeatWaiters.clear(); + this.link?.send({ type: "stop", reason }); + this.link?.close(); + this.link = null; + this.detach?.(); + this.detach = null; + this.notify({ type: "stopped", reason }); + } + + private attachLifecycle(): void { + const w = globalThis as { addEventListener?: typeof addEventListener; removeEventListener?: typeof removeEventListener }; + const doc = (globalThis as { document?: Document }).document; + if (typeof w.addEventListener !== "function") return; + const listeners: Array<[EventTarget, string, EventListener]> = []; + const on = (target: EventTarget, type: string, listener: EventListener) => { + target.addEventListener(type, listener); + listeners.push([target, type, listener]); + }; + const target = globalThis as unknown as EventTarget; + if (this.releaseOn.includes("pagehide")) { + on(target, "pagehide", () => void this.release("pagehide")); + // Restored from the back/forward cache. + on(target, "pageshow", (event) => { + if ((event as PageTransitionEvent).persisted) this.resume(); + }); + } + if (this.releaseOn.includes("hidden") && doc) { + on(doc, "visibilitychange", () => { + if (doc.visibilityState === "hidden") void this.release("hidden"); + else if (doc.visibilityState === "visible") this.resume(); + }); + } + this.detach = () => { + for (const [t, type, listener] of listeners) t.removeEventListener(type, listener); + }; + } + + private notify(event: BrowserWorkerEvent): void { + try { + this.options.onEvent?.(event); + } catch { + // Observers must not affect task semantics. + } + } +} diff --git a/src/builder.ts b/src/builder.ts index 81d84ce..8bc3955 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -33,8 +33,16 @@ import { type SubSequenceBlock, type TryCatchBlock, } from "./schema.js"; +import type { RuntimePlacement } from "./types.js"; export interface StepOptions { + /** + * Placement requirements, emitted as `params.$runtime`. For example + * `{ runtime_kinds: ["browser"], requires_human_ui: true }` dispatches the + * step to a browser tab running `BrowserWorker`, even when a server worker + * registers the same handler. + */ + runtime?: RuntimePlacement; delay?: DelaySpec; retry?: RetryPolicy; /** Wall-clock timeout for a single execution, in milliseconds. */ @@ -81,12 +89,13 @@ export class WorkflowBuilder = Record = Record { return new WorkflowBuilder(name, namespace); } + +/** Attach `$runtime` placement to step params (params must be an object). */ +export function withPlacement

    (params: P | undefined, placement: RuntimePlacement): P & { $runtime: RuntimePlacement } { + if (params !== undefined && (params === null || typeof params !== "object" || Array.isArray(params))) { + throw new TypeError("runtime placement requires object step params"); + } + return { ...(params ?? {}), $runtime: placement } as P & { $runtime: RuntimePlacement }; +} diff --git a/src/client.ts b/src/client.ts index c578f90..eb0a5bf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import type { Orch8ClientConfig, @@ -68,8 +67,12 @@ import type { FailRequest, HeartbeatRequest, HeartbeatResponse, + ReleaseRequest, + CreateBrowserSessionRequest, + BrowserSession, } from "./types.js"; import { ContinuityClient } from "./continuity.js"; +import { randomUUID } from "./internal/random-uuid.js"; import { JobsClient } from "./jobs.js"; export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -744,6 +747,43 @@ export class Orch8Client { return this.post(`/workers/tasks/${this.e(id)}/heartbeat`, body); } + /** + * Voluntarily give a claimed task back (`started: false` returns it to + * `pending`; `started: true` marks a side-effecting step's receipt Unknown). + */ + releaseTask(id: string, body: ReleaseRequest): Promise { + return this.post(`/workers/tasks/${this.e(id)}/release`, body); + } + + /** + * Mint a short-lived browser worker token (`POST /runtimes/browser-sessions`). + * Call this from your application backend with an operator/admin key and + * hand the result to `BrowserWorker`'s `getToken`; never ship the API key to + * the browser. The token is bound to `kind=browser`, the runtime id and the + * handler allowlist, and only works on the worker task endpoints. + */ + async createBrowserSession(request: CreateBrowserSessionRequest): Promise { + if (!Array.isArray(request.handlers) || request.handlers.length === 0) { + throw new TypeError("createBrowserSession requires at least one handler"); + } + const body: Record = { handlers: request.handlers }; + if (request.runtimeId !== undefined) body.runtime_id = request.runtimeId; + if (request.ttlSecs !== undefined) body.ttl_secs = request.ttlSecs; + if (request.queues !== undefined) body.queues = request.queues; + const res = await this.post<{ + token: string; + runtime_id: string; + expires_at: string; + handlers?: string[]; + }>("/runtimes/browser-sessions", body); + return { + token: res.token, + runtimeId: res.runtime_id, + expiresAt: res.expires_at, + handlers: res.handlers ?? request.handlers, + }; + } + listWorkerTasks(filter?: Record): Promise { const params = filter ? `?${new URLSearchParams(filter)}` : ""; return this.get(`/workers/tasks${params}`); diff --git a/src/index.ts b/src/index.ts index f0999ea..d577bed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,8 +14,16 @@ export { type WaitForJobOptions, } from "./jobs.js"; export { ContinuityClient, type JsonObject, type QueryValue } from "./continuity.js"; -export { Orch8Worker, type WorkerConfig, type HandlerFn, type WorkerRuntimeStats } from "./worker.js"; -export { WorkflowBuilder, workflow, type StepOptions } from "./builder.js"; +export { + Orch8Worker, + workerTaskContext, + type WorkerConfig, + type HandlerFn, + type WorkerRuntimeStats, + type WorkerTaskContext, + type WorkerCapabilities, +} from "./worker.js"; +export { WorkflowBuilder, workflow, withPlacement, type StepOptions } from "./builder.js"; export { verifyWebhookSignature } from "./webhook.js"; export { durableAgentHandler, type AgentRunner, type DurableAgentTask } from "./adapters.js"; export type * from "./types.js"; diff --git a/src/internal/capabilities.ts b/src/internal/capabilities.ts new file mode 100644 index 0000000..dd6fed5 --- /dev/null +++ b/src/internal/capabilities.ts @@ -0,0 +1,30 @@ +import type { RuntimeCapabilities, RuntimeKind } from "../types.js"; +import type { WorkerCapabilities } from "../worker.js"; + +/** Server maximum lifetime of a capability advertisement, in seconds. */ +export const MAX_CAPABILITY_TTL_SECS = 300; + +/** + * Fresh capability advertisement for one poll: bound to `runtimeId` (the + * poll's `worker_id`), `handlers` defaulting to the registered handler names, + * and an observation window of at most five minutes starting now. + */ +export function buildAdvertisement( + caps: WorkerCapabilities, + runtimeId: string, + handlerNames: string[], + nowMs: number, + defaultKind: RuntimeKind = "server", +): RuntimeCapabilities { + const { ttlSecs, kind, trust, handlers, ...facts } = caps; + const ttl = Math.min(Math.max(ttlSecs ?? MAX_CAPABILITY_TTL_SECS, 1), MAX_CAPABILITY_TTL_SECS); + return { + ...facts, + runtime_id: runtimeId, + kind: kind ?? defaultKind, + trust: trust ?? "registered", + handlers: handlers ?? handlerNames, + observed_at: new Date(nowMs).toISOString(), + expires_at: new Date(nowMs + ttl * 1000).toISOString(), + }; +} diff --git a/src/internal/random-uuid.ts b/src/internal/random-uuid.ts new file mode 100644 index 0000000..1f72259 --- /dev/null +++ b/src/internal/random-uuid.ts @@ -0,0 +1,14 @@ +/** + * RFC 4122 v4 UUID from the Web Crypto global (Node 19+, edge runtimes, + * browsers) or `node:crypto` (Node 18). The indirect require keeps edge + * bundlers from resolving the builtin, so modules using this stay runnable on + * Cloudflare Workers without `nodejs_compat`. + */ +export function randomUUID(): string { + const globalCrypto = (globalThis as { crypto?: Crypto }).crypto; + if (typeof globalCrypto?.randomUUID === "function") return globalCrypto.randomUUID(); + const req = typeof require === "function" ? require : undefined; + const nodeCrypto = req?.("node:crypto") as { randomUUID?: () => string } | undefined; + if (typeof nodeCrypto?.randomUUID === "function") return nodeCrypto.randomUUID(); + throw new Error("crypto.randomUUID is not available in this runtime"); +} diff --git a/src/internal/task-context.ts b/src/internal/task-context.ts new file mode 100644 index 0000000..bbd18d5 --- /dev/null +++ b/src/internal/task-context.ts @@ -0,0 +1,27 @@ +import type { WorkerTask } from "../types.js"; + +/** Lease facts for the task being handled (all `null` against older servers). */ +export interface WorkerTaskContext { + /** + * Deterministic idempotency key of the step's effect receipt. Send it to + * downstream APIs (for example as `Idempotency-Key`) so a retry cannot + * duplicate the side effect. + */ + effectId: string | null; + /** Lease the server expects between heartbeats, in seconds. */ + leaseSecs: number | null; + /** Continuity owner epoch at dispatch. */ + continuityEpoch: number | null; + /** This worker's id (the lease `worker_id`). */ + workerId: string; +} + +/** Build the handler context for a claimed task. */ +export function workerTaskContext(task: WorkerTask, workerId: string): WorkerTaskContext { + return { + effectId: typeof task.effect_id === "string" ? task.effect_id : null, + leaseSecs: typeof task.lease_secs === "number" ? task.lease_secs : null, + continuityEpoch: typeof task.continuity_epoch === "number" ? task.continuity_epoch : null, + workerId, + }; +} diff --git a/src/push/core.ts b/src/push/core.ts index a1abe46..009d169 100644 --- a/src/push/core.ts +++ b/src/push/core.ts @@ -1,6 +1,7 @@ import type { Orch8Client } from "../client.js"; import type { WorkerTask } from "../types.js"; import type { HandlerFn } from "../worker.js"; +import { workerTaskContext } from "../internal/task-context.js"; import { subtle, toHex } from "../internal/webcrypto.js"; /** @@ -221,7 +222,7 @@ export async function runClaimedTask( let output: unknown; try { if (!handler) throw new Error(`no handler registered for "${task.handler_name}"`); - output = await withTimeout(handler(task), task.timeout_ms); + output = await withTimeout(handler(task, workerTaskContext(task, workerId)), task.timeout_ms); } catch (err) { const message = errorMessage(err); const retryable = handler === undefined ? false diff --git a/src/serverless/cloudflare.ts b/src/serverless/cloudflare.ts new file mode 100644 index 0000000..728831f --- /dev/null +++ b/src/serverless/cloudflare.ts @@ -0,0 +1,123 @@ +import { drainOnce, serverlessWorkerId, type DrainOptions, type DrainResult } from "./drain.js"; + +/** Minimal Cloudflare `ExecutionContext`. */ +export interface CloudflareExecutionContext { + waitUntil(promise: Promise): void; +} + +/** Minimal Cloudflare `ScheduledController`. */ +export interface CloudflareScheduledController { + scheduledTime?: number; + cron?: string; +} + +export interface CloudflareExecutorOptions extends Omit { + /** + * Wall-clock budget per trigger. Workers expose no remaining-time API, so + * set this below your plan's limit (CPU time for fetch, wall time for Cron + * Triggers). Default: 25000 ms. + */ + budgetMs?: number; + /** + * Shared secret for the `fetch` trigger (`Authorization: Bearer `, + * `POST` only). Without it the fetch trigger answers 404, so a public + * Worker URL cannot be used to drain your queue. + */ + triggerSecret?: string; +} + +export interface CloudflareExecutor { + /** Cron Trigger entry point. */ + scheduled(controller: CloudflareScheduledController, env: Env, ctx?: CloudflareExecutionContext): Promise; + /** On-demand trigger (e.g. from a webhook or a queue push). */ + fetch(request: Request, env: Env, ctx?: CloudflareExecutionContext): Promise; + /** Run one drain directly (both triggers use it). */ + drain(env: Env): Promise; +} + +function constantTimeEqual(a: string, b: string): boolean { + const len = Math.max(a.length, b.length); + let diff = a.length ^ b.length; + for (let i = 0; i < len; i += 1) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); + return diff === 0; +} + +function json(status: number, body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +/** + * Cloudflare Workers executor (module syntax). Uses only `fetch`, timers and + * `AbortController` — no Node APIs, no `nodejs_compat` flag. Import it, and + * the client, from `@orch8.io/sdk/serverless`: + * + * ```ts + * import { Orch8Client, createCloudflareExecutor } from "@orch8.io/sdk/serverless"; + * + * export default createCloudflareExecutor((env: Env) => ({ + * client: new Orch8Client({ baseUrl: env.ORCH8_URL, headers: { "x-api-key": env.ORCH8_API_KEY } }), + * handlers: { "enrich-lead": async (task) => enrich(task.params) }, + * triggerSecret: env.ORCH8_TRIGGER_SECRET, + * })); + * ``` + * + * Options may be a function of `env` (bindings); they are built once per + * `env` object. Capability advertisements default to `kind: "edge"`. + */ +export function createCloudflareExecutor>( + options: CloudflareExecutorOptions | ((env: Env) => CloudflareExecutorOptions), +): CloudflareExecutor { + const cache = new WeakMap(); + let fixed: (CloudflareExecutorOptions & { workerId: string }) | undefined; + + const resolve = (env: Env): CloudflareExecutorOptions & { workerId: string } => { + if (typeof options !== "function") { + fixed ??= { ...options, workerId: options.workerId ?? serverlessWorkerId("cf") }; + return fixed; + } + const key = (env ?? {}) as object; + let built = cache.get(key); + if (!built) { + const o = options(env); + built = { ...o, workerId: o.workerId ?? serverlessWorkerId("cf") }; + cache.set(key, built); + } + return built; + }; + + const drain = (env: Env): Promise => { + const { budgetMs = 25_000, triggerSecret: _secret, ...rest } = resolve(env); + const now = rest.now ?? Date.now; + return drainOnce({ + defaultKind: "edge", + ...rest, + deadlineMs: now() + Math.max(0, budgetMs), + }); + }; + + return { + drain, + async scheduled(_controller, env, ctx) { + const run = drain(env); + ctx?.waitUntil(run); + await run; + }, + async fetch(request, env) { + const { triggerSecret } = resolve(env); + if (!triggerSecret) return json(404, { error: "fetch_trigger_disabled" }); + if (request.method.toUpperCase() !== "POST") { + return json(405, { error: "method_not_allowed" }, { allow: "POST" }); + } + const auth = request.headers.get("authorization") ?? ""; + const presented = auth.replace(/^Bearer\s+/i, ""); + if (!auth || !constantTimeEqual(presented, triggerSecret)) { + return json(401, { error: "unauthorized" }); + } + const result = await drain(env); + return json(result.stoppedBy === "poll_error" ? 502 : 200, result); + }, + }; +} diff --git a/src/serverless/drain.ts b/src/serverless/drain.ts new file mode 100644 index 0000000..9964cc4 --- /dev/null +++ b/src/serverless/drain.ts @@ -0,0 +1,454 @@ +/** + * `drainOnce` — claim, run and acknowledge a bounded batch of worker tasks + * inside a fixed time budget, then return. The building block for executors + * that live for one invocation (AWS Lambda, Cloudflare Workers, cron jobs). + * + * It speaks the same lease protocol as `Orch8Worker` through the SDK client + * (`poll` → `heartbeat`* → `complete` | `fail`, echoing `claim_epoch`) and + * adds one rule: nothing may still hold a lease when the budget ends. Tasks + * that are still running at `deadlineMs - releaseMarginMs` are released with + * `started: true` (the engine marks a side-effecting step's receipt Unknown + * and re-dispatches), and claims that cannot fit the remaining budget are + * released with `started: false` so another executor picks them up at once. + * + * This module graph is free of Node built-ins: it only needs `fetch` (via the + * client), timers and `AbortController`. + */ +import type { Orch8Client } from "../client.js"; +import type { RuntimeKind, WorkerPollResponse, WorkerTask } from "../types.js"; +import type { WorkerCapabilities } from "../worker.js"; +import { buildAdvertisement } from "../internal/capabilities.js"; +import { workerTaskContext, type WorkerTaskContext } from "../internal/task-context.js"; + +/** The subset of `Orch8Client` the drain loop uses (the worker lease API). */ +export type LeaseClient = Pick< + Orch8Client, + "pollTaskBatch" | "pollTaskBatchFromQueue" | "heartbeatTask" | "completeTask" | "failTask" | "releaseTask" +>; + +/** Handler context: the worker lease facts plus the invocation budget. */ +export interface ServerlessTaskContext extends WorkerTaskContext { + /** Aborted when the budget ends (the task is then released) or the lease is lost. */ + signal: AbortSignal; + /** Epoch ms by which the handler must have returned (release point). */ + deadlineMs: number; + /** Milliseconds left until {@link deadlineMs}, never negative. */ + remainingMs(): number; + /** Extend the lease now (the drain loop also heartbeats on its own). */ + heartbeat(): Promise; +} + +/** + * A step handler. Plain `Orch8Worker` handlers (`HandlerFn`) are accepted + * unchanged; they simply ignore the extra context fields. + */ +export type ServerlessHandler = (task: WorkerTask, context: ServerlessTaskContext) => Promise; + +export interface DrainOptions { + /** SDK client (`Orch8Client`) whose credentials may poll and acknowledge worker tasks. */ + client: LeaseClient; + /** Handler map keyed by the step's handler name; each name is polled. */ + handlers: Record; + /** + * Absolute deadline (epoch milliseconds) by which every lease taken by this + * drain must be settled — completed, failed or released. Adapters derive it + * from the platform's remaining time minus a safety margin. + */ + deadlineMs: number; + /** Lease `worker_id`. Default: `serverless-`. */ + workerId?: string; + /** Maximum tasks claimed by this drain (N). Default: 10. */ + maxTasks?: number; + /** Time reserved before `deadlineMs` for release calls. Default: 1000 ms. */ + releaseMarginMs?: number; + /** Do not claim new work when less than this remains before the release point. Default: 1000 ms. */ + minTaskBudgetMs?: number; + /** Upper bound on the heartbeat interval; lease hints shorten it. Default: 15000 ms. */ + heartbeatIntervalMs?: number; + /** Claim from this queue (`POST /workers/tasks/poll/queue`) instead of by handler. */ + queueName?: string; + /** Advertise runtime capabilities so `$runtime`-placed tasks can be claimed. */ + capabilities?: WorkerCapabilities; + /** `kind` used when `capabilities.kind` is unset. Default: `server`. */ + defaultKind?: RuntimeKind; + /** Clock override (epoch ms) for tests. */ + now?: () => number; +} + +export type DrainTaskOutcome = + /** Handler returned and the completion was acknowledged. */ + | "completed" + /** Handler threw (or timed out / had no handler) and the failure was acknowledged. */ + | "failed" + /** Still running at the release point; released with `started: true`. */ + | "released" + /** Claimed but not started because it could not fit the budget; released with `started: false`. */ + | "released_unstarted" + /** A heartbeat answered 404/409: the engine reclaimed it; never acknowledged. */ + | "lease_lost" + /** The engine rejected the completion/failure/release request. */ + | "ack_rejected"; + +export interface DrainTaskResult { + task_id: string; + handler_name: string; + outcome: DrainTaskOutcome; + error?: string; +} + +export type DrainStopReason = + /** No handler had pending work. */ + | "empty" + /** The budget ran out (or the remaining work could not fit it). */ + | "deadline" + /** `maxTasks` were claimed. */ + | "max_tasks" + /** Every poll failed. */ + | "poll_error"; + +export interface DrainResult { + workerId: string; + claimed: number; + completed: number; + failed: number; + /** Released with either `started` value. */ + released: number; + leaseLost: number; + ackRejected: number; + stoppedBy: DrainStopReason; + tasks: DrainTaskResult[]; + pollErrors: string[]; +} + +const DEFAULT_MAX_TASKS = 10; +const DEFAULT_RELEASE_MARGIN_MS = 1_000; +const DEFAULT_MIN_TASK_BUDGET_MS = 1_000; +const DEFAULT_HEARTBEAT_MS = 15_000; +const MIN_HEARTBEAT_MS = 250; + +type Timer = ReturnType; + +interface InFlight { + task: WorkerTask; + lost: boolean; + onLost: () => void; +} + +type Settled = + | { kind: "ok"; value: unknown } + | { kind: "error"; error: unknown } + | { kind: "deadline" } + | { kind: "timeout" } + | { kind: "lost" }; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function statusOf(err: unknown): number | undefined { + const status = (err as { status?: unknown } | null)?.status; + return typeof status === "number" ? status : undefined; +} + +function randomSuffix(): string { + const c = (globalThis as { crypto?: Crypto }).crypto; + if (typeof c?.randomUUID === "function") return c.randomUUID().slice(0, 8); + return Math.random().toString(16).slice(2, 10); +} + +/** Default worker id for a serverless executor: `-`. */ +export function serverlessWorkerId(prefix = "serverless"): string { + return `${prefix}-${randomSuffix()}`; +} + +function delay(ms: number): { promise: Promise; cancel: () => void } { + let timer: Timer | undefined; + const promise = new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, ms)); + }); + return { promise, cancel: () => clearTimeout(timer) }; +} + +/** + * Claim up to `maxTasks` tasks for the configured handlers, run them within + * the budget, heartbeat while they run, acknowledge results, and release any + * lease still held at `deadlineMs - releaseMarginMs`. Never throws for + * engine or handler errors; the outcome of every claimed task is reported. + */ +export async function drainOnce(options: DrainOptions): Promise { + const now = options.now ?? Date.now; + const client = options.client; + const handlers = options.handlers; + const handlerNames = Object.keys(handlers); + if (handlerNames.length === 0) throw new TypeError("drainOnce requires at least one handler"); + if (!Number.isFinite(options.deadlineMs)) throw new TypeError("drainOnce requires a finite deadlineMs"); + + const workerId = options.workerId ?? serverlessWorkerId(); + const maxTasks = Math.max(1, Math.floor(options.maxTasks ?? DEFAULT_MAX_TASKS)); + const releaseMarginMs = Math.max(0, options.releaseMarginMs ?? DEFAULT_RELEASE_MARGIN_MS); + const minTaskBudgetMs = Math.max(0, options.minTaskBudgetMs ?? DEFAULT_MIN_TASK_BUDGET_MS); + const stopAt = options.deadlineMs - releaseMarginMs; + const remaining = (): number => Math.max(0, stopAt - now()); + + const result: DrainResult = { + workerId, + claimed: 0, + completed: 0, + failed: 0, + released: 0, + leaseLost: 0, + ackRejected: 0, + stoppedBy: "empty", + tasks: [], + pollErrors: [], + }; + const inFlight = new Map(); + let heartbeatMs = Math.max(MIN_HEARTBEAT_MS, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS); + + const release = (task: WorkerTask, started: boolean): Promise => + client.releaseTask(task.id, { worker_id: workerId, claim_epoch: task.claim_epoch, started }); + + const heartbeatOne = async (entry: InFlight): Promise => { + if (entry.lost) throw new Error("lease lost"); + try { + await client.heartbeatTask(entry.task.id, { worker_id: workerId, claim_epoch: entry.task.claim_epoch }); + } catch (err) { + const status = statusOf(err); + if (status === 404 || status === 409) entry.onLost(); + throw err; + } + }; + + const poll = async (handlerName: string, limit: number): Promise => { + const capabilities = options.capabilities + ? buildAdvertisement(options.capabilities, workerId, handlerNames, now(), options.defaultKind) + : undefined; + const body = { + handler_name: handlerName, + worker_id: workerId, + limit, + ...(capabilities ? { capabilities } : {}), + }; + return options.queueName + ? client.pollTaskBatchFromQueue({ ...body, queue_name: options.queueName }) + : client.pollTaskBatch(body); + }; + + /** Poll raced against the release point; claims that arrive late are handed back. */ + const pollInBudget = async (handlerName: string, limit: number): Promise => { + const pending = poll(handlerName, limit); + const timer = delay(remaining()); + const winner = await Promise.race([ + pending.then((batch) => ({ batch }) as const), + timer.promise.then(() => "deadline" as const), + ]).finally(timer.cancel); + if (winner === "deadline") { + void pending.then( + (late) => Promise.allSettled(late.tasks.map((t) => release(t, false))), + () => undefined, + ); + return "deadline"; + } + const batch = winner.batch; + const hints = [heartbeatMs]; + if (typeof batch.heartbeat_interval_secs === "number") hints.push(batch.heartbeat_interval_secs * 1000); + if (typeof batch.lease_secs === "number") hints.push(batch.lease_secs * 500); + heartbeatMs = Math.max(MIN_HEARTBEAT_MS, Math.min(...hints)); + return batch.tasks; + }; + + const record = (task: WorkerTask, outcome: DrainTaskOutcome, error?: string): DrainTaskResult => { + const entry: DrainTaskResult = { task_id: task.id, handler_name: task.handler_name, outcome }; + if (error !== undefined) entry.error = error; + return entry; + }; + + const acknowledge = async ( + task: WorkerTask, + outcome: "completed" | "failed" | "released" | "released_unstarted", + send: () => Promise, + error?: string, + ): Promise => { + try { + await send(); + return record(task, outcome, error); + } catch (ackErr) { + return record(task, "ack_rejected", errorMessage(ackErr)); + } + }; + + const fail = (task: WorkerTask, message: string, retryable: boolean): Promise => + acknowledge( + task, + "failed", + () => client.failTask(task.id, { worker_id: workerId, claim_epoch: task.claim_epoch, message, retryable }), + message, + ); + + const runTask = async (task: WorkerTask): Promise => { + const handler = handlers[task.handler_name]; + if (!handler) return fail(task, `no handler registered for "${task.handler_name}"`, false); + + // The step's own timeout cannot fit what is left: give it back unstarted. + if (typeof task.timeout_ms === "number" && task.timeout_ms > 0 && task.timeout_ms > remaining()) { + return acknowledge(task, "released_unstarted", () => release(task, false)); + } + if (remaining() <= 0) return acknowledge(task, "released_unstarted", () => release(task, false)); + + const controller = new AbortController(); + let signalLost: () => void = () => undefined; + const lost = new Promise((resolve) => { + signalLost = () => resolve({ kind: "lost" }); + }); + const entry: InFlight = { + task, + lost: false, + onLost: () => { + if (entry.lost) return; + entry.lost = true; + signalLost(); + }, + }; + inFlight.set(task.id, entry); + + const context: ServerlessTaskContext = { + ...workerTaskContext(task, workerId), + signal: controller.signal, + deadlineMs: stopAt, + remainingMs: remaining, + heartbeat: () => heartbeatOne(entry), + }; + const running = Promise.resolve() + .then(() => handler(task, context)) + .then( + (value): Settled => ({ kind: "ok", value }), + (error): Settled => ({ kind: "error", error }), + ); + const deadline = delay(remaining()); + const timeout = + typeof task.timeout_ms === "number" && task.timeout_ms > 0 ? delay(task.timeout_ms) : undefined; + const contenders: Promise[] = [ + running, + lost, + deadline.promise.then((): Settled => ({ kind: "deadline" })), + ]; + if (timeout) contenders.push(timeout.promise.then((): Settled => ({ kind: "timeout" }))); + + let settled: Settled; + try { + settled = await Promise.race(contenders); + } finally { + deadline.cancel(); + timeout?.cancel(); + } + + try { + switch (settled.kind) { + case "deadline": + controller.abort(new Error("serverless budget exhausted; task released")); + return await acknowledge(task, "released", () => release(task, true)); + case "lost": + controller.abort(new Error("lease lost")); + return record(task, "lease_lost"); + case "timeout": + controller.abort(new Error("task timed out")); + if (entry.lost) return record(task, "lease_lost"); + return await fail(task, "task timed out", true); + case "error": { + if (entry.lost) return record(task, "lease_lost"); + const err = settled.error; + const retryable = err instanceof Error && "retryable" in err + ? Boolean((err as { retryable?: unknown }).retryable) + : true; + return await fail(task, errorMessage(err), retryable); + } + case "ok": + if (entry.lost) return record(task, "lease_lost"); + return await acknowledge(task, "completed", () => + client.completeTask(task.id, { + worker_id: workerId, + claim_epoch: task.claim_epoch, + output: settled.value ?? {}, + }), + ); + } + } finally { + inFlight.delete(task.id); + } + }; + + let heartbeatTimer: ReturnType | undefined; + const startHeartbeats = (): void => { + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = setInterval(() => { + for (const entry of inFlight.values()) { + if (!entry.lost) void heartbeatOne(entry).catch(() => undefined); + } + }, heartbeatMs); + }; + + try { + for (;;) { + if (result.claimed >= maxTasks) { + result.stoppedBy = "max_tasks"; + break; + } + if (remaining() < Math.max(minTaskBudgetMs, 1)) { + result.stoppedBy = "deadline"; + break; + } + + const round: WorkerTask[] = []; + let pollFailures = 0; + let outOfTime = false; + for (const handlerName of handlerNames) { + const capacity = maxTasks - result.claimed - round.length; + if (capacity <= 0) break; + try { + const tasks = await pollInBudget(handlerName, capacity); + if (tasks === "deadline") { + outOfTime = true; + break; + } + round.push(...tasks.slice(0, capacity)); + // An over-delivering server: never hold more than `maxTasks` leases. + for (const extra of tasks.slice(capacity)) { + result.claimed += 1; + result.tasks.push(await acknowledge(extra, "released_unstarted", () => release(extra, false))); + } + } catch (err) { + pollFailures += 1; + result.pollErrors.push(`${handlerName}: ${errorMessage(err)}`); + } + if (options.queueName) break; // one queue poll covers every handler + } + + if (round.length === 0) { + if (outOfTime) result.stoppedBy = "deadline"; + else if (pollFailures > 0) result.stoppedBy = "poll_error"; + else result.stoppedBy = "empty"; + break; + } + + result.claimed += round.length; + startHeartbeats(); + const outcomes = await Promise.all(round.map(runTask)); + result.tasks.push(...outcomes); + if (outOfTime || outcomes.some((o) => o.outcome === "released" || o.outcome === "released_unstarted")) { + result.stoppedBy = "deadline"; + break; + } + } + } finally { + if (heartbeatTimer) clearInterval(heartbeatTimer); + } + + for (const task of result.tasks) { + if (task.outcome === "completed") result.completed += 1; + else if (task.outcome === "failed") result.failed += 1; + else if (task.outcome === "released" || task.outcome === "released_unstarted") result.released += 1; + else if (task.outcome === "lease_lost") result.leaseLost += 1; + else if (task.outcome === "ack_rejected") result.ackRejected += 1; + } + return result; +} diff --git a/src/serverless/index.ts b/src/serverless/index.ts new file mode 100644 index 0000000..bbb26a7 --- /dev/null +++ b/src/serverless/index.ts @@ -0,0 +1,34 @@ +/** + * `@orch8.io/sdk/serverless` — run step handlers in functions that live for + * one invocation (AWS Lambda, Cloudflare Workers, cron jobs). Each invocation + * drains a bounded batch of worker tasks inside its time budget and releases + * any lease it cannot finish, using the regular worker lease API. + * + * Edge-safe: nothing in this module graph imports Node built-ins, so the + * Cloudflare adapter runs without `nodejs_compat`. `Orch8Client` is re-exported + * so edge code never has to import the root entry (which carries Node-only + * helpers such as webhook verification). + */ +export { Orch8Client, Orch8Error } from "../client.js"; +export { + drainOnce, + serverlessWorkerId, + type DrainOptions, + type DrainResult, + type DrainStopReason, + type DrainTaskOutcome, + type DrainTaskResult, + type LeaseClient, + type ServerlessHandler, + type ServerlessTaskContext, +} from "./drain.js"; +export { createLambdaExecutor, type LambdaContextLike, type LambdaExecutorOptions } from "./lambda.js"; +export { + createCloudflareExecutor, + type CloudflareExecutionContext, + type CloudflareExecutor, + type CloudflareExecutorOptions, + type CloudflareScheduledController, +} from "./cloudflare.js"; +export type { WorkerCapabilities } from "../worker.js"; +export type { WorkerTask, RuntimeKind } from "../types.js"; diff --git a/src/serverless/lambda.ts b/src/serverless/lambda.ts new file mode 100644 index 0000000..fc7ef26 --- /dev/null +++ b/src/serverless/lambda.ts @@ -0,0 +1,53 @@ +import { drainOnce, serverlessWorkerId, type DrainOptions, type DrainResult } from "./drain.js"; + +/** The part of the AWS Lambda context object the executor needs. */ +export interface LambdaContextLike { + getRemainingTimeInMillis(): number; + awsRequestId?: string; + functionName?: string; +} + +export interface LambdaExecutorOptions extends Omit { + /** + * Time kept free before Lambda's own timeout, on top of `releaseMarginMs` + * (response serialisation, cold-start jitter, clock skew). Default: 3000 ms. + */ + safetyMarginMs?: number; +} + +/** + * AWS Lambda executor. Wire it to an EventBridge schedule (or any trigger — + * the event is ignored): each invocation claims up to `maxTasks` tasks for + * the configured handlers, runs them inside + * `context.getRemainingTimeInMillis() - safetyMarginMs`, heartbeats, and + * releases whatever is still running before the function times out. + * + * ```ts + * export const handler = createLambdaExecutor({ + * client: new Orch8Client({ baseUrl: process.env.ORCH8_URL!, headers: { "x-api-key": process.env.ORCH8_API_KEY! } }), + * handlers: { "resize-image": async (task, ctx) => resize(task.params, { signal: ctx.signal }) }, + * maxTasks: 5, + * }); + * ``` + * + * `options` may be a factory; it is called once per container, on the first + * invocation. The worker id is stable per container (`lambda-`) + * unless `workerId` is set. + */ +export function createLambdaExecutor( + options: LambdaExecutorOptions | (() => LambdaExecutorOptions), +): (event: unknown, context: LambdaContextLike) => Promise { + let resolved: LambdaExecutorOptions | undefined; + let workerId: string | undefined; + return async (_event, context) => { + resolved ??= typeof options === "function" ? options() : options; + workerId ??= resolved.workerId ?? serverlessWorkerId("lambda"); + if (!context || typeof context.getRemainingTimeInMillis !== "function") { + throw new TypeError("createLambdaExecutor needs the Lambda context (getRemainingTimeInMillis)"); + } + const { safetyMarginMs = 3_000, ...drain } = resolved; + const now = drain.now ?? Date.now; + const deadlineMs = now() + context.getRemainingTimeInMillis() - Math.max(0, safetyMarginMs); + return drainOnce({ ...drain, workerId, deadlineMs }); + }; +} diff --git a/src/types.ts b/src/types.ts index 02b65b5..2a1453c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -227,6 +227,108 @@ export interface WorkerTask { claim_epoch?: number; resume_checkpoint?: unknown; checkpoint_seq: number; + /** + * Deterministic idempotency key of the step's effect receipt. Pass it to + * downstream APIs as an idempotency key. Absent on older servers. + */ + effect_id?: string; + /** Continuity owner epoch at dispatch; fenced together with `claim_epoch`. */ + continuity_epoch?: number; + /** Lease the server expects between heartbeats for this task, in seconds. */ + lease_secs?: number; + /** Echo of the step's `$runtime.runtime_id` placement, for debugging. */ + target_runtime_id?: string; + /** Echo of the step's `$runtime.runtime_kinds` placement, for debugging. */ + runtime_kinds?: RuntimeKind[]; +} + +/** Kind of runtime node that claims worker tasks. */ +export type RuntimeKind = "server" | "edge" | "mobile" | "desktop" | "browser"; + +/** How strongly a runtime's identity is verified. Self-advertised runtimes may claim at most `registered`. */ +export type RuntimeTrustLevel = "unverified" | "registered" | "signed" | "attested"; + +/** Current network path of a runtime. */ +export type RuntimeConnectivity = "offline" | "metered" | "wifi" | "ethernet"; + +/** + * Short-lived (at most five minutes) capability advertisement sent with a + * poll or to `/runtimes/register`. `runtime_id` must equal the poll's `worker_id`. + */ +export interface RuntimeCapabilities { + runtime_id: string; + kind: RuntimeKind; + trust: RuntimeTrustLevel; + handlers?: string[]; + plugins?: string[]; + /** Credential binding references only, never secret material. */ + credentials?: string[]; + regions?: string[]; + hardware?: string[]; + offline_capable?: boolean; + connectivity?: RuntimeConnectivity; + battery_percent?: number; + estimated_cost_microunits?: number; + estimated_latency_ms?: number; + draining?: boolean; + capsule_signing_public_key?: string; + /** RFC 3339 timestamp. */ + observed_at: string; + /** RFC 3339 timestamp, at most five minutes after `observed_at`. */ + expires_at: string; +} + +/** + * Step placement requirements, carried as `params.$runtime`. Use the + * builder's `runtime` step option to set it. + */ +export interface RuntimePlacement { + /** Only runtime nodes of these kinds may claim the step. */ + runtime_kinds?: RuntimeKind[]; + /** Only this specific node may claim the step (a per-device mailbox). */ + runtime_id?: string; + /** Requires a runtime with a human-facing UI (mobile, desktop or browser). */ + requires_human_ui?: boolean; + requires_network?: boolean; + minimum_trust?: RuntimeTrustLevel; + handlers?: string[]; + plugins?: string[]; + regions?: string[]; + hardware?: string[]; + /** + * Credential references the step needs. Steps placed on browser runtimes + * must not reference credentials; the engine fails them permanently. + */ + credentials?: string[]; +} + +/** Request body of `POST /runtimes/browser-sessions`. */ +export interface CreateBrowserSessionRequest { + /** Stable id for the browser node; the server generates one when omitted. */ + runtimeId?: string; + /** Handler allowlist; poll returns only tasks for these handlers. */ + handlers: string[]; + /** Token lifetime in seconds (server default 900, max 3600). */ + ttlSecs?: number; + queues?: string[]; +} + +/** A short-lived, browser-scoped worker credential. */ +export interface BrowserSession { + token: string; + runtimeId: string; + /** RFC 3339 timestamp. */ + expiresAt: string; + handlers: string[]; +} + +/** Body of `POST /workers/tasks/{id}/release`. */ +export interface ReleaseRequest { + worker_id: string; + claim_epoch?: number; + /** Whether the handler already started; `true` makes a side-effecting step's receipt `Unknown`. */ + started: boolean; + [key: string]: unknown; } export interface ClusterNode { @@ -634,6 +736,7 @@ export interface PollRequest { worker_id?: string; limit?: number; capacity?: number; + capabilities?: RuntimeCapabilities; [key: string]: unknown; } diff --git a/src/worker.ts b/src/worker.ts index c4fcdb1..ef2eab2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,7 +1,44 @@ -import type { WorkerTask } from "./types.js"; +import type { + RuntimeCapabilities, + RuntimeConnectivity, + RuntimeKind, + RuntimeTrustLevel, + WorkerTask, +} from "./types.js"; import { Orch8Client, Orch8Error } from "./client.js"; +import { workerTaskContext, type WorkerTaskContext } from "./internal/task-context.js"; +import { buildAdvertisement } from "./internal/capabilities.js"; -export type HandlerFn = (task: WorkerTask) => Promise; +export { workerTaskContext, type WorkerTaskContext }; + +export type HandlerFn = (task: WorkerTask, context: WorkerTaskContext) => Promise; + +/** + * Capabilities advertised with every poll so the worker can claim tasks whose + * `params.$runtime` placement requires them. `runtime_id` is always the + * worker id, `handlers` defaults to the configured handler names, and the + * observation window is refreshed on each poll. + */ +export interface WorkerCapabilities { + /** Default: `server`. */ + kind?: RuntimeKind; + /** Default: `registered` (the most a self-advertised runtime may claim). */ + trust?: RuntimeTrustLevel; + handlers?: string[]; + plugins?: string[]; + /** Credential binding references only, never secret material. */ + credentials?: string[]; + regions?: string[]; + hardware?: string[]; + offline_capable?: boolean; + connectivity?: RuntimeConnectivity; + battery_percent?: number; + estimated_cost_microunits?: number; + estimated_latency_ms?: number; + capsule_signing_public_key?: string; + /** Advertisement lifetime, capped at 300 (the server maximum). Default: 300. */ + ttlSecs?: number; +} export interface WorkerRuntimeStats { running: boolean; @@ -38,6 +75,11 @@ export interface WorkerConfig { * This ensures consistent auth, headers, and base URL handling. */ client?: Orch8Client; + /** + * Advertise runtime capabilities with each poll. Without them the worker + * only claims tasks that carry no `$runtime` requirements. + */ + capabilities?: WorkerCapabilities; } export class Orch8Worker { @@ -55,16 +97,18 @@ export class Orch8Worker { handlers: Record; onTaskComplete?: (task: WorkerTask, output: unknown) => void; onTaskFail?: (task: WorkerTask, error: string) => void; + capabilities?: WorkerCapabilities; }; private readonly client: Orch8Client; + private stopping = false; private pollHints = new Map(); private heartbeatIntervals = new Map(); /** Claims whose lease was lost (heartbeat 404/409); never acknowledged. */ private lostTasks = new Set(); private running = false; - private pollTimers = new Map(); - private heartbeatTimer: NodeJS.Timeout | null = null; + private pollTimers = new Map>(); + private heartbeatTimer: ReturnType | null = null; private inFlightTasks = new Map(); private executingPromises = new Set>(); private concurrencySemaphore: number; @@ -87,6 +131,7 @@ export class Orch8Worker { circuitBreakerCheck: config.circuitBreakerCheck ?? false, onTaskComplete: config.onTaskComplete, onTaskFail: config.onTaskFail, + capabilities: config.capabilities, }; this.client = config.client ?? new Orch8Client({ baseUrl: this.config.engineUrl, retry: false }); this.concurrencySemaphore = this.config.maxConcurrent; @@ -95,6 +140,7 @@ export class Orch8Worker { async start(): Promise { if (this.running) return; this.running = true; + this.stopping = false; // Start a poll loop per handler using dynamic scheduling for backoff. for (const handlerName of Object.keys(this.config.handlers)) { @@ -131,6 +177,7 @@ export class Orch8Worker { async stop(): Promise { this.running = false; + this.stopping = true; for (const timer of this.pollTimers.values()) clearTimeout(timer); this.pollTimers.clear(); if (this.heartbeatTimer) { @@ -140,7 +187,7 @@ export class Orch8Worker { // Drain in-flight tasks with a hard timeout. const drainTimeoutMs = 30_000; const drain = Promise.allSettled(Array.from(this.executingPromises)); - let timer: NodeJS.Timeout | undefined; + let timer: ReturnType | undefined; const timeout = new Promise((resolve) => { timer = setTimeout(resolve, drainTimeoutMs); }); try { await Promise.race([drain, timeout]); @@ -158,6 +205,30 @@ export class Orch8Worker { }; } + /** Fresh capability advertisement for one poll, or undefined when none is configured. */ + private advertisement(): RuntimeCapabilities | undefined { + const caps = this.config.capabilities; + if (!caps) return undefined; + return buildAdvertisement(caps, this.config.workerId, Object.keys(this.config.handlers), Date.now()); + } + + /** + * Give claimed-but-unstarted tasks back (`started: false`) so they return to + * `pending` at once instead of waiting for lease expiry. Servers without + * `/release` answer 404/405; the lease then expires as before. + */ + private async releaseUnstarted(tasks: WorkerTask[]): Promise { + await Promise.allSettled( + tasks.map((task) => + this.client.releaseTask(task.id, { + worker_id: this.config.workerId, + claim_epoch: task.claim_epoch, + started: false, + }), + ), + ); + } + private async poll(handlerName: string): Promise { if (!this.running || this.concurrencySemaphore <= 0) return; @@ -174,10 +245,12 @@ export class Orch8Worker { try { const limit = Math.min(this.concurrencySemaphore, this.config.maxConcurrent); + const capabilities = this.advertisement(); const batch = await this.client.pollTaskBatch({ handler_name: handlerName, worker_id: this.config.workerId, limit, + ...(capabilities ? { capabilities } : {}), }); const tasks = batch.tasks; this.pollHints.set(handlerName, batch.poll_after_ms ?? 0); @@ -187,19 +260,29 @@ export class Orch8Worker { (batch.lease_secs ?? Infinity) * 500, ); this.heartbeatIntervals.set(handlerName, heartbeatMs); - if (this.running) this.resetHeartbeatTimer(); // Reset backoff on successful poll. this.consecutiveFailures.set(handlerName, 0); + const unstarted: WorkerTask[] = []; for (const task of tasks) { - if (this.concurrencySemaphore <= 0) break; + // Claimed while shutting down, or beyond free capacity: never started. + if (!this.running || this.stopping || this.concurrencySemaphore <= 0) { + unstarted.push(task); + continue; + } this.concurrencySemaphore--; this.inFlightTasks.set(task.id, task); const p = this.executeTask(task); this.executingPromises.add(p); p.finally(() => this.executingPromises.delete(p)); } + if (this.running) this.resetHeartbeatTimer(); + if (unstarted.length > 0) { + const released = this.releaseUnstarted(unstarted); + this.executingPromises.add(released); + void released.finally(() => this.executingPromises.delete(released)); + } } catch { // Network error — increment failures for backoff. this.consecutiveFailures.set( @@ -221,7 +304,7 @@ export class Orch8Worker { try { let output: unknown; try { - output = await this.withTimeout(handler(task), task.timeout_ms); + output = await this.withTimeout(handler(task, workerTaskContext(task, this.config.workerId)), task.timeout_ms); } catch (err) { const message = err instanceof Error ? err.message : String(err); // Uncaught errors are transient unless explicitly flagged otherwise. @@ -256,7 +339,7 @@ export class Orch8Worker { private async withTimeout(promise: Promise, timeoutMs: number | null): Promise { if (!timeoutMs) return promise; - let timer: NodeJS.Timeout | undefined; + let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("task timed out")), timeoutMs); }); @@ -287,7 +370,15 @@ export class Orch8Worker { private heartbeatDelayMs = 0; private resetHeartbeatTimer(): void { - const delay = Math.min(this.config.heartbeatIntervalMs, ...this.heartbeatIntervals.values()); + // Heartbeat at half of the shortest lease in play: the poll-level hint and + // each in-flight task's own `lease_secs` (mobile/browser leases differ). + const taskLeaseMs = Array.from(this.inFlightTasks.values(), (t) => + typeof t.lease_secs === "number" && t.lease_secs > 0 ? t.lease_secs * 500 : Infinity, + ); + const delay = Math.max( + 250, + Math.min(this.config.heartbeatIntervalMs, ...this.heartbeatIntervals.values(), ...taskLeaseMs), + ); if (this.heartbeatTimer && delay === this.heartbeatDelayMs) return; if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); this.heartbeatDelayMs = delay; diff --git a/test/e2e/browser-worker.e2e.test.ts b/test/e2e/browser-worker.e2e.test.ts new file mode 100644 index 0000000..44ec85b --- /dev/null +++ b/test/e2e/browser-worker.e2e.test.ts @@ -0,0 +1,656 @@ +// @vitest-environment jsdom +/** + * End-to-end: `BrowserWorker` in a jsdom page against a REAL Orch8 engine. + * + * Skipped unless both are set: + * ORCH8_E2E_URL engine base URL, e.g. http://127.0.0.1:18480 + * ORCH8_E2E_ADMIN_KEY the engine's root API key (ORCH8_API_KEY) + * Optional: + * ORCH8_E2E_ALLOWED_ORIGIN an origin listed in the engine's ORCH8_CORS_ORIGINS + * (default http://localhost:5173) + * + * Run with `npm run test:e2e:browser`. + * + * jsdom has no `Worker`, so every BrowserWorker runs its lease loop with + * `mode: "main"` (the documented fallback); handlers, page-data helpers, + * lifecycle listeners and the lease protocol are the same code paths. + * Requests go over Node's real `fetch`. Node does not enforce CORS, so the + * CORS scenario checks the engine's preflight responses directly. + */ +import { randomUUID } from "node:crypto"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { Orch8Client } from "../../src/client.js"; +import { + BrowserWorker, + readForm, + querySelectorText, + querySelectorAllText, + type BrowserTaskContext, + type BrowserWorkerEvent, + type BrowserWorkerOptions, +} from "../../src/browser/index.js"; + +const BASE_URL = (process.env.ORCH8_E2E_URL ?? "").replace(/\/$/, ""); +const ADMIN_KEY = process.env.ORCH8_E2E_ADMIN_KEY ?? ""; +const ALLOWED_ORIGIN = process.env.ORCH8_E2E_ALLOWED_ORIGIN ?? "http://localhost:5173"; +const enabled = BASE_URL !== "" && ADMIN_KEY !== ""; + +const CREDENTIAL_DISPATCH_ERROR = "steps placed on browser runtimes cannot receive credentials"; + +// --------------------------------------------------------------------------- +// Real fetch, bridged for jsdom +// --------------------------------------------------------------------------- + +// Under the jsdom environment `AbortController` is jsdom's, which Node's +// fetch (undici) rejects as a foreign signal. Bridge the abort by racing it. +const nodeFetch: typeof fetch = globalThis.fetch.bind(globalThis); +const realFetch: typeof fetch = (input, init = {}) => { + const { signal, ...rest } = init; + const request = nodeFetch(input, rest); + if (!signal) return request; + return new Promise((resolve, reject) => { + if (signal.aborted) reject(new DOMException("aborted", "AbortError")); + signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); + request.then(resolve, reject); + }); +}; + +interface Exchange { + path: string; + method: string; + requestBody: unknown; + status: number; + responseText: string; +} + +/** A fetch that records every exchange (the whole raw payload the tab received). */ +function recordingFetch(onResponse?: (exchange: Exchange) => void) { + const exchanges: Exchange[] = []; + const fetchImpl: typeof fetch = async (input, init = {}) => { + const url = String(input); + const response = await realFetch(input, init); + const text = response.status === 204 ? "" : await response.clone().text(); + let requestBody: unknown = null; + try { + requestBody = typeof init.body === "string" ? JSON.parse(init.body) : null; + } catch { + requestBody = init.body; + } + const exchange: Exchange = { + path: url.slice(BASE_URL.length), + method: init.method ?? "GET", + requestBody, + status: response.status, + responseText: text, + }; + exchanges.push(exchange); + onResponse?.(exchange); + return response; + }; + return { fetch: fetchImpl, exchanges }; +} + +function polledTasks(exchange: Exchange): unknown[] { + if (exchange.path !== "/workers/tasks/poll" || exchange.status !== 200) return []; + const body = JSON.parse(exchange.responseText) as { tasks?: unknown[] }; + return body.tasks ?? []; +} + +async function raw( + method: string, + path: string, + body: unknown, + headers: Record, +): Promise<{ status: number; text: string; headers: Headers }> { + const init: RequestInit = { method, headers: { "Content-Type": "application/json", ...headers } }; + if (body !== undefined) init.body = JSON.stringify(body); + const res = await realFetch(`${BASE_URL}${path}`, init); + return { status: res.status, text: await res.text(), headers: res.headers }; +} + +async function waitFor(what: string, fn: () => Promise, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + let last: unknown; + while (Date.now() < deadline) { + try { + const value = await fn(); + if (value !== undefined && value !== null && value !== false) return value as T; + } catch (err) { + last = err; + } + await new Promise((r) => setTimeout(r, 150)); + } + throw new Error(`timed out waiting for ${what}${last ? `: ${String(last)}` : ""}`); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// --------------------------------------------------------------------------- +// Backend (Node, admin key) + page (jsdom) fixtures +// --------------------------------------------------------------------------- + +function suffix(): string { + return randomUUID().slice(0, 8); +} + +function backend(tenantId: string): Orch8Client { + return new Orch8Client({ + baseUrl: BASE_URL, + tenantId, + headers: { "x-api-key": ADMIN_KEY }, + retry: false, + fetch: realFetch, + }); +} + +function step(id: string, handler: string, params: Record = {}) { + return { type: "step", id, handler, params }; +} + +async function runSequence( + client: Orch8Client, + name: string, + blocks: unknown[], + context?: Record, +): Promise { + const sequence = await client.createSequence({ name: `${name}-${suffix()}`, blocks }); + const instance = await client.createInstance({ + sequence_id: sequence.id, + ...(context ? { context } : {}), + }); + return instance.id; +} + +async function waitForState(client: Orch8Client, instanceId: string, states: string[], timeoutMs = 20_000): Promise { + return waitFor(`instance ${instanceId} in ${states.join("|")}`, async () => { + const instance = await client.getInstance(instanceId); + return states.includes(instance.state) ? instance.state : undefined; + }, timeoutMs); +} + +function tokenRuntimeId(token: string): string { + const payload = token.slice("bst_".length).split(".")[0]; + return (JSON.parse(Buffer.from(payload, "base64url").toString()) as { runtime_id: string }).runtime_id; +} + +const workers: BrowserWorker[] = []; + +interface PageWorker { + worker: BrowserWorker; + events: BrowserWorkerEvent[]; + tokenCalls: () => number; + tokens: string[]; + exchanges: Exchange[]; +} + +type PageWorkerOptions = Partial & { ttlSecs?: number; onExchange?: (e: Exchange) => void }; + +function pageWorker( + client: Orch8Client, + handlers: string[], + register: (worker: BrowserWorker) => void, + options: PageWorkerOptions = {}, +): PageWorker { + const events: BrowserWorkerEvent[] = []; + const tokens: string[] = []; + let calls = 0; + const { ttlSecs, onExchange, onEvent, ...rest } = options; + const recorder = recordingFetch(onExchange); + const worker = new BrowserWorker({ + baseUrl: BASE_URL, + mode: "main", + pollIntervalMs: 200, + maxBackoffMs: 1_000, + // The page asks ITS backend for a token; here the backend is the admin client. + getToken: async () => { + calls += 1; + const session = await client.createBrowserSession({ handlers, ttlSecs: ttlSecs ?? 600 }); + tokens.push(session.token); + return session; + }, + fetch: recorder.fetch, + onEvent: (event) => { + events.push(event); + onEvent?.(event); + }, + ...rest, + }); + register(worker); + workers.push(worker); + return { worker, events, tokenCalls: () => calls, tokens, exchanges: recorder.exchanges }; +} + +function el( + tag: K, + attrs: Record = {}, + children: Array = [], +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value); + for (const child of children) node.append(child); + return node; +} + +function renderCheckoutPage(): void { + const express = el("option", { value: "exp" }, ["Express"]); + express.selected = true; + const gift = el("input", { name: "gift", type: "checkbox", value: "yes" }); + gift.checked = true; + document.body.replaceChildren( + el("h1", { id: "title" }, ["Order #42"]), + el("ul", { class: "items" }, [el("li", {}, ["Keyboard"]), el("li", {}, ["Mouse"])]), + el("form", { id: "checkout" }, [ + el("input", { name: "name", value: "Ada Lovelace" }), + el("input", { name: "email", type: "email", value: "ada@example.com" }), + el("input", { name: "password", type: "password", value: "hunter2" }), + el("input", { name: "csrf", type: "hidden", value: "csrf-123" }), + gift, + el("select", { name: "shipping" }, [el("option", { value: "std" }, ["Standard"]), express]), + el("textarea", { name: "note" }, ["Leave at the door"]), + ]), + ); +} + +/** The browser step handler of the happy path: form values plus some DOM text. */ +async function readPage(input: unknown) { + return { + form: readForm("#checkout"), + title: querySelectorText("#title"), + items: querySelectorAllText(".items li"), + input, + }; +} + +afterEach(async () => { + while (workers.length > 0) { + const worker = workers.pop()!; + await worker.stop("test_done").catch(() => undefined); + } +}); + +// --------------------------------------------------------------------------- + +describe.skipIf(!enabled)("BrowserWorker against a real engine", () => { + beforeAll(async () => { + const health = await realFetch(`${BASE_URL}/health/ready`); + expect(health.ok, `engine at ${BASE_URL} is not ready`).toBe(true); + renderCheckoutPage(); + }); + + it("1. happy path: server builtin → browser read_page → server builtin that uses the page data", { timeout: 60_000 }, async () => { + renderCheckoutPage(); + const client = backend(`bw-happy-${suffix()}`); + const handler = "read_page"; + const page = pageWorker(client, [handler], (w) => w.register(handler, readPage)); + await page.worker.start(); + + const instanceId = await runSequence(client, "bw-happy", [ + step("A", "log", { message: "hello from the server" }), + step("B", handler, { greeting: "{{outputs.A.message}}", $runtime: { runtime_kinds: ["browser"] } }), + step("C", "log", { message: "{{outputs.B.form.email}} | {{outputs.B.title}} | {{outputs.B.form.shipping}}" }), + ]); + expect(await waitForState(client, instanceId, ["completed", "failed"])).toBe("completed"); + + const outputs = await client.getOutputs(instanceId); + const byBlock = new Map(outputs.map((o) => [o.block_id, o.output as Record])); + const b = byBlock.get("B")!; + expect(b.form).toEqual({ + name: "Ada Lovelace", + email: "ada@example.com", + gift: "yes", + shipping: "exp", + note: "Leave at the door", + }); + expect(b.title).toBe("Order #42"); + expect(b.items).toEqual(["Keyboard", "Mouse"]); + // `$runtime` is stripped from the params the handler sees; templates resolve. + expect(b.input).toEqual({ greeting: "hello from the server" }); + expect(byBlock.get("C")!.message).toBe("ada@example.com | Order #42 | exp"); + + // Provenance: the audit log records the browser runtime that produced B. + const runtimeId = tokenRuntimeId(page.tokens[0]); + const audit = (await client.listAuditLog(instanceId)) as unknown as Array>; + const provenance = audit.find((entry) => entry.event_type === "worker_output_provenance" && entry.block_id === "B"); + expect(provenance, JSON.stringify(audit.map((e) => e.event_type))).toBeTruthy(); + expect(provenance!.details.runtime_kind).toBe("browser"); + expect(provenance!.details.runtime_id).toBe(runtimeId); + expect(provenance!.details.untrusted_page_data).toBe(true); + expect(page.events.some((e) => e.type === "task_completed")).toBe(true); + }); + + it("2a. no secrets: a browser-placed step referencing a credential fails at dispatch and never reaches the tab", { timeout: 60_000 }, async () => { + const tenantId = `bw-secret-${suffix()}`; + const client = backend(tenantId); + const credentialId = `cred-${suffix()}`; + await client.createCredential({ id: credentialId, name: "stripe", kind: "api_key", value: `sk_live_${suffix()}`, tenant_id: tenantId }); + const handler = `form_${suffix()}`; + const calls: unknown[] = []; + const page = pageWorker(client, [handler], (w) => + w.register(handler, (input) => { + calls.push(input); + return {}; + }), + ); + await page.worker.start(); + + const instanceId = await runSequence(client, "bw-secret-placed", [ + step("form", handler, { api_key: `credentials://${credentialId}`, $runtime: { runtime_kinds: ["browser"] } }), + ]); + expect(await waitForState(client, instanceId, ["completed", "failed"])).toBe("failed"); + + // Permanent: no worker task was ever created, and the tab never saw one. + await sleep(1_500); + expect(await waitForState(client, instanceId, ["failed"])).toBe("failed"); + const tasks = await client.listWorkerTasks({ handler_name: handler }); + expect(tasks).toHaveLength(0); + expect(calls).toHaveLength(0); + expect(page.events.some((e) => e.type === "task_started")).toBe(false); + const polls = page.exchanges.filter((e) => e.path === "/workers/tasks/poll"); + expect(polls.length).toBeGreaterThan(0); + expect(polls.flatMap(polledTasks)).toEqual([]); + }); + + it("2a. no secrets: the dispatch failure records the documented reason where operators can see it", { timeout: 60_000 }, async () => { + const tenantId = `bw-secret-reason-${suffix()}`; + const client = backend(tenantId); + const credentialId = `cred-${suffix()}`; + await client.createCredential({ id: credentialId, name: "stripe", kind: "api_key", value: `sk_live_${suffix()}`, tenant_id: tenantId }); + const instanceId = await runSequence(client, "bw-secret-reason", [ + step("form", `form_${suffix()}`, { api_key: `credentials://${credentialId}`, $runtime: { runtime_kinds: ["browser"] } }), + ]); + expect(await waitForState(client, instanceId, ["completed", "failed"])).toBe("failed"); + const [instance, outputs, audit] = await Promise.all([ + client.getInstance(instanceId), + client.getOutputs(instanceId), + client.listAuditLog(instanceId), + ]); + expect(JSON.stringify({ instance, outputs, audit })).toContain(CREDENTIAL_DISPATCH_ERROR); + }); + + it("2b. no secrets: a server step uses a credential, the following browser step receives none of it", { timeout: 60_000 }, async () => { + const tenantId = `bw-secret2-${suffix()}`; + const client = backend(tenantId); + const secret = `sk_live_${randomUUID().replace(/-/g, "")}`; + const credentialId = `cred-${suffix()}`; + await client.createCredential({ id: credentialId, name: "stripe", kind: "api_key", value: secret, tenant_id: tenantId }); + const serverHandler = `charge_${suffix()}`; + const browserHandler = `confirm_${suffix()}`; + + let received: { params: unknown; ctx: BrowserTaskContext } | null = null; + const page = pageWorker(client, [browserHandler], (w) => + w.register(browserHandler, (input, ctx) => { + received = { params: input, ctx }; + return { confirmed: true }; + }), + ); + await page.worker.start(); + + const instanceId = await runSequence( + client, + "bw-secret-server-then-browser", + [ + // Server builtin that resolves the credential. + step("audit_key", "log", { message: `credentials://${credentialId}` }), + // Server worker (the backend process) that uses the credential. + step("charge", serverHandler, { api_key: `credentials://${credentialId}`, amount: 42 }), + step("confirm", browserHandler, { amount: 42, $runtime: { runtime_kinds: ["browser"] } }), + ], + { + data: { note: "public", stripe: `credentials://${credentialId}` }, + config: { stripe_secret: secret }, + }, + ); + + // The backend's server worker claims the credential step and sees the secret. + const serverTask = await waitFor("server task", async () => { + const tasks = await client.pollTasks({ handler_name: serverHandler, worker_id: "backend-worker" }); + return tasks[0]; + }); + expect((serverTask.params as Record).api_key).toBe(secret); + await client.completeTask(serverTask.id, { + worker_id: "backend-worker", + claim_epoch: serverTask.claim_epoch, + output: { charge_id: "ch_1", status: "succeeded" }, + }); + + expect(await waitForState(client, instanceId, ["completed", "failed"])).toBe("completed"); + expect(received).not.toBeNull(); + const got = received as unknown as { params: unknown; ctx: BrowserTaskContext }; + const context = got.ctx.context as Record; + expect(context.config).toBeUndefined(); + expect(context.audit).toBeUndefined(); + expect(context.data?.note).toBe("public"); + expect(context.data?.stripe).toBeUndefined(); + expect(got.params).toEqual({ amount: 42 }); + + // The whole payload the tab received (every poll response, every request + // it made, and what the handler saw) never contains the secret. + const everything = JSON.stringify({ exchanges: page.exchanges, params: got.params, context: got.ctx.context }); + expect(everything).not.toContain(secret); + expect(everything).not.toContain(`credentials://${credentialId}`); + expect(page.exchanges.flatMap(polledTasks)).toHaveLength(1); + }); + + it("3. token scope: the browser token only reaches its own lease protocol", { timeout: 30_000 }, async () => { + const tenantId = `bw-scope-${suffix()}`; + const client = backend(tenantId); + const handler = `scoped_${suffix()}`; + const session = await client.createBrowserSession({ handlers: [handler], ttlSecs: 120 }); + expect(session.token).toMatch(/^bst_/); + const auth = { "x-api-key": session.token }; + const now = new Date(); + const caps = (kind: string, runtimeId: string) => ({ + runtime_id: runtimeId, + kind, + trust: "registered", + handlers: [handler], + offline_capable: false, + observed_at: now.toISOString(), + expires_at: new Date(now.getTime() + 120_000).toISOString(), + }); + + const cases: Array<[string, string, string, unknown, number]> = [ + ["list tasks", "GET", "/workers/tasks", undefined, 403], + ["worker commands", "POST", "/workers/commands", { worker_id: session.runtimeId, command: "drain" }, 403], + ["create sequence", "POST", "/sequences", { id: randomUUID(), tenant_id: tenantId, namespace: "default", name: "x", version: 1, blocks: [] }, 403], + ["mint another session", "POST", "/runtimes/browser-sessions", { handlers: [handler] }, 403], + ["poll as another runtime_id", "POST", "/workers/tasks/poll", { handler_name: handler, worker_id: randomUUID() }, 403], + ["poll advertising kind=server", "POST", "/workers/tasks/poll", { handler_name: handler, worker_id: session.runtimeId, capabilities: caps("server", session.runtimeId) }, 403], + ["poll advertising another runtime_id", "POST", "/workers/tasks/poll", { handler_name: handler, worker_id: session.runtimeId, capabilities: caps("browser", randomUUID()) }, 403], + ["poll a handler outside the allowlist", "POST", "/workers/tasks/poll", { handler_name: "charge_card", worker_id: session.runtimeId }, 403], + ["poll a queue outside the allowlist", "POST", "/workers/tasks/poll/queue", { queue_name: "payments", handler_name: handler, worker_id: session.runtimeId }, 403], + ["complete as another worker", "POST", `/workers/tasks/${randomUUID()}/complete`, { worker_id: "server-worker", claim_epoch: 1, output: {} }, 403], + ]; + const results: Array<{ name: string; status: number; expected: number; text: string }> = []; + for (const [name, method, path, body, expected] of cases) { + const res = await raw(method, path, body, auth); + results.push({ name, status: res.status, expected, text: res.text.slice(0, 200) }); + } + expect(results.filter((r) => r.status !== r.expected)).toEqual([]); + + // The legitimate call works with the same token. + const ok = await raw("POST", "/workers/tasks/poll", { handler_name: handler, worker_id: session.runtimeId }, auth); + expect(ok.status).toBe(200); + // A forged token (claims changed, signature kept) is 401. + const [payload, signature] = session.token.slice(4).split("."); + const claims = JSON.parse(Buffer.from(payload, "base64url").toString()) as { handlers: string[] }; + claims.handlers.push("charge_card"); + const forged = `bst_${Buffer.from(JSON.stringify(claims)).toString("base64url")}.${signature}`; + const forgedPoll = await raw("POST", "/workers/tasks/poll", { handler_name: "charge_card", worker_id: session.runtimeId }, { "x-api-key": forged }); + expect(forgedPoll.status).toBe(401); + }); + + describe("4. tab close", () => { + async function closeTabScenario(trigger: "during_poll" | "on_task_started") { + const tenantId = `bw-close-${suffix()}`; + const client = backend(tenantId); + const handler = `close_${suffix()}`; + const firstRuns: unknown[] = []; + let fired = false; + const releases: Exchange[] = []; + const firstTab = pageWorker( + client, + [handler], + (w) => + w.register(handler, (input) => { + firstRuns.push(input); + return { tab: "first" }; + }), + { + onExchange: (exchange) => { + if (exchange.path.endsWith("/release")) releases.push(exchange); + // The claim is committed server-side; the tab closes before the + // poll response (and so the handler) is processed. + if (trigger === "during_poll" && !fired && polledTasks(exchange).length > 0) { + fired = true; + window.dispatchEvent(new Event("pagehide")); + } + }, + onEvent: (event) => { + // The facade has the task; the handler has not been invoked yet. + if (trigger === "on_task_started" && !fired && event.type === "task_started") { + fired = true; + window.dispatchEvent(new Event("pagehide")); + } + }, + }, + ); + + const instanceId = await runSequence(client, "bw-close", [step("B", handler, { $runtime: { runtime_kinds: ["browser"] } })]); + await firstTab.worker.start(); + await waitFor("pagehide fired", async () => fired); + const releasedAt = Date.now(); + const release = await waitFor("release request", async () => releases[0]); + expect(release.status).toBe(204); + expect((release.requestBody as { started: boolean }).started).toBe(false); + + // Back to pending right away — not after the 30 s browser lease. + const pending = await waitFor("task back to pending", async () => { + const tasks = await client.listWorkerTasks({ handler_name: handler }); + return tasks.find((t) => t.state === "pending"); + }, 5_000); + expect(Date.now() - releasedAt).toBeLessThan(5_000); + expect(pending.worker_id ?? null).toBeNull(); + expect(firstRuns).toHaveLength(0); + await firstTab.worker.stop(); + expect(releases).toHaveLength(1); + + // Another tab (a new session) picks it up and completes it. + const secondTab = pageWorker(client, [handler], (w) => w.register(handler, () => ({ tab: "second" }))); + await secondTab.worker.start(); + expect(await waitForState(client, instanceId, ["completed", "failed"])).toBe("completed"); + const outputs = await client.getOutputs(instanceId); + expect(outputs.find((o) => o.block_id === "B")?.output).toEqual({ tab: "second" }); + expect(firstRuns).toHaveLength(0); + } + + it("pagehide while the claiming poll is in flight releases with started=false", { timeout: 60_000 }, () => + closeTabScenario("during_poll")); + + it("pagehide after the tab received the task but before the handler ran releases with started=false", { timeout: 60_000 }, () => + closeTabScenario("on_task_started")); + }); + + it("5. token expiry and refresh: getToken is called again, work continues, an expired token is 401", { timeout: 60_000 }, async () => { + const tenantId = `bw-ttl-${suffix()}`; + const client = backend(tenantId); + const handler = `ttl_${suffix()}`; + const page = pageWorker(client, [handler], (w) => w.register(handler, () => ({ done: true })), { ttlSecs: 4 }); + await page.worker.start(); + const firstToken = page.tokens[0]; + + // Outlive the first token. + await sleep(6_000); + expect(page.tokenCalls()).toBeGreaterThanOrEqual(2); + expect(page.events.some((e) => e.type === "token_refreshed")).toBe(true); + expect(page.worker.running).toBe(true); + + // Work created after the first token died still runs. + const instanceId = await runSequence(client, "bw-ttl", [step("B", handler, { $runtime: { runtime_kinds: ["browser"] } })]); + expect(await waitForState(client, instanceId, ["completed", "failed"])).toBe("completed"); + expect(page.exchanges.filter((e) => e.path.endsWith("/complete")).map((e) => e.status)).toEqual([200]); + // The worker refreshed ahead of expiry: it never used a dead token. + expect(page.exchanges.filter((e) => e.status === 401)).toEqual([]); + + // The expired token itself is refused. + const expired = await raw("POST", "/workers/tasks/poll", { handler_name: handler, worker_id: tokenRuntimeId(firstToken) }, { "x-api-key": firstToken }); + expect(expired.status).toBe(401); + }); + + describe("6. oversized output", () => { + const big = () => ({ blob: "x".repeat(1024 * 1024 + 512 * 1024) }); + + async function oversized(options: PageWorkerOptions) { + const tenantId = `bw-big-${suffix()}`; + const client = backend(tenantId); + const handler = `big_${suffix()}`; + const page = pageWorker(client, [handler], (w) => w.register(handler, big), options); + await page.worker.start(); + const instanceId = await runSequence(client, "bw-big", [step("B", handler, { $runtime: { runtime_kinds: ["browser"] } })]); + expect(await waitForState(client, instanceId, ["completed", "failed"])).toBe("failed"); + const fails = await waitFor("fail ack", async () => { + const list = page.exchanges.filter((e) => e.path.endsWith("/fail")); + return list.length > 0 ? list : undefined; + }); + expect(fails).toHaveLength(1); + expect(fails[0].status).toBe(200); + expect((fails[0].requestBody as { retryable: boolean }).retryable).toBe(false); + + // Nothing oversized was stored: no output for B, the task has no output, one attempt only. + const outputs = await client.getOutputs(instanceId); + const stored = outputs.find((o) => o.block_id === "B"); + expect(JSON.stringify(stored?.output ?? null).length).toBeLessThan(64 * 1024); + const tasks = await client.listWorkerTasks({ handler_name: handler }); + expect(tasks).toHaveLength(1); + expect(tasks[0].state).toBe("failed"); + expect(tasks[0].error_retryable).toBe(false); + expect(JSON.stringify(tasks[0].output ?? null).length).toBeLessThan(1024); + return page; + } + + it("is refused in the tab (client guard) as a non-retryable failure", { timeout: 60_000 }, async () => { + const page = await oversized({}); + const failed = page.events.find((e) => e.type === "task_failed") as { retryable: boolean } | undefined; + expect(failed?.retryable).toBe(false); + expect(page.exchanges.filter((e) => e.path.endsWith("/complete"))).toHaveLength(0); + }); + + it("is refused by the engine (413) when the client guard is raised, and fails non-retryably", { timeout: 60_000 }, async () => { + const page = await oversized({ maxOutputBytes: 16 * 1024 * 1024 }); + expect(page.exchanges.filter((e) => e.path.endsWith("/complete")).map((e) => e.status)).toEqual([413]); + // The tab turned the 413 into a permanent failure instead of holding the claim until the lease expired. + const [fail] = page.exchanges.filter((e) => e.path.endsWith("/fail")); + expect((fail.requestBody as { message: string }).message).toMatch(/too large \(413\).*maximum is 1048576/); + }); + }); + + it("7. CORS: allowed origin passes the preflight with x-api-key; a disallowed origin is refused", { timeout: 30_000 }, async () => { + const preflight = (origin: string) => + realFetch(`${BASE_URL}/workers/tasks/poll`, { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type,x-api-key", + }, + }); + const allowed = await preflight(ALLOWED_ORIGIN); + expect(allowed.status).toBeLessThan(300); + expect(allowed.headers.get("access-control-allow-origin")).toBe(ALLOWED_ORIGIN); + expect((allowed.headers.get("access-control-allow-headers") ?? "").toLowerCase()).toContain("x-api-key"); + expect((allowed.headers.get("access-control-allow-methods") ?? "").toUpperCase()).toContain("POST"); + + const denied = await preflight("https://evil.example"); + expect(denied.headers.get("access-control-allow-origin")).toBeNull(); + + // Actual requests: the allowed origin gets the header, a disallowed one does not. + const client = backend(`bw-cors-${suffix()}`); + const session = await client.createBrowserSession({ handlers: ["cors_probe"], ttlSecs: 60 }); + const body = { handler_name: "cors_probe", worker_id: session.runtimeId }; + const fromAllowed = await raw("POST", "/workers/tasks/poll", body, { "x-api-key": session.token, Origin: ALLOWED_ORIGIN }); + expect(fromAllowed.status).toBe(200); + expect(fromAllowed.headers.get("access-control-allow-origin")).toBe(ALLOWED_ORIGIN); + const fromDenied = await raw("POST", "/workers/tasks/poll", body, { "x-api-key": session.token, Origin: "https://evil.example" }); + expect(fromDenied.headers.get("access-control-allow-origin")).toBeNull(); + }); +});