diff --git a/.actor/actor.json b/.actor/actor.json new file mode 100644 index 0000000..3c78a0c --- /dev/null +++ b/.actor/actor.json @@ -0,0 +1,94 @@ +{ + "actorSpecification": 1, + "name": "code-runtime", + "title": "Code Runtime", + "description": "Runs one JS script in a sandboxed Actor with an apify binding (Actors, datasets, KV stores). Worth it for bulk work: 50+ record filter/sort/aggregate, or 10+ item fan-out (e.g. many page visits). Skip for a single <10-item lookup \u2014 call the Actor directly instead. Read the README first.", + "version": "0.1", + "buildTag": "latest", + "usesStandbyMode": false, + "defaultRunOptions": { + "timeoutSecs": 900, + "memoryMbytes": 1024 + }, + "input": { + "title": "Code Runtime Input", + "description": "The program to run inside the sandbox.", + "type": "object", + "schemaVersion": 1, + "properties": { + "code": { + "title": "Code", + "type": "string", + "description": "JavaScript executed in the sandbox with `apify` and `console` globals; only console output is captured and pushed to the dataset as { stdout, stderr, exitCode, statusMessage } — a top-level `return` value is NOT captured. Before writing code that calls a specific Actor, check its real input field names first — via fetch-actor-details (outside this script, before you write it) or apify.actor.get({ actorId }) (inside it, for an Actor picked at runtime). Do not guess field names from memory; a wrong one throws a fast 400, but costs a wasted round trip. Print a small JSON summary of the result — never dump full datasets. Write top-level `await` statements directly in the script; do NOT wrap your logic in an async function you call without awaiting (e.g. `async function main(){...}; main()`) — the script returns as soon as the top-level body finishes, silently discarding anything still pending, with no error. Every apify.* method takes ONE options object keyed by id, e.g. apify.actor.call({ actorId, input }), apify.dataset.listItems({ datasetId, limit }) — this is NOT the public apify-client SDK's curried apify.actor(id).call(input) shape. If a prior attempt already logged a nested run's defaultDatasetId/defaultKeyValueStoreId (visible in your own earlier turns), reuse it — do NOT re-run the same Actor call with identical input, that wastes compute on a call that already succeeded. apify.actor.call/run.waitForFinish may return non-terminal (READY/RUNNING) once the 60s wait cap elapses — that is NOT a failure, poll again instead of throwing.", + "editor": "javascript" + }, + "maxActorRuns": { + "title": "Max Actor runs", + "type": "integer", + "description": "Caps how many Actor runs this script may start in total across actor.start/actor.call/actor.callAndGetItems. Starting one more once the limit is reached throws inside the script. Omit for no limit.", + "minimum": 1 + }, + "maxTotalChargeUsd": { + "title": "Max total charge (USD)", + "type": "number", + "description": "Execution-wide spending budget across every Actor run this script starts — distinct from a single call's own maxTotalChargeUsd, which only caps that one run. Each run's own cap is clamped so the combined total never exceeds this budget; starting a run once it's exhausted throws inside the script. Omit for no limit.", + "minimum": 0 + }, + "defaultTimeoutSecs": { + "title": "Default Actor run timeout (seconds)", + "type": "integer", + "description": "Applied as timeoutSecs to actor.start/actor.call/actor.callAndGetItems calls that don't specify their own. Omit to use the Apify API's own default.", + "minimum": 1 + } + }, + "required": ["code"] + }, + "output": { + "actorOutputSchemaVersion": 1, + "title": "Code Runtime Output", + "description": "One dataset item { stdout, stderr, exitCode, statusMessage } (exitCode: 0=returned, 1=threw); see the exitCode field description for the timeout/OOM case.", + "type": "object", + "properties": { + "output": { + "type": "string", + "title": "Execution output", + "template": "{{links.apiDefaultDatasetUrl}}/items" + } + } + }, + "storages": { + "dataset": { + "actorSpecification": 1, + "description": "Present only if the script ran to completion (returned or threw). A run-level timeout or OOM kill produces zero items for that run — the calling Actor-run's own status (SUCCEEDED vs FAILED/TIMED-OUT) is the signal for that case, not item presence.", + "fields": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "stdout": { + "type": "string", + "title": "stdout", + "description": "Captured console.log/console.info output." + }, + "stderr": { + "type": "string", + "title": "stderr", + "description": "Captured console.error/console.warn output, plus the thrown error (or compile failure) message when exitCode is 1." + }, + "exitCode": { + "type": "integer", + "enum": [0, 1], + "title": "Exit code", + "description": "0 = script returned normally, 1 = script threw (or failed to compile)." + }, + "statusMessage": { + "type": "string", + "title": "Status message", + "description": "Prose form of the same signal: 'Script completed' / 'Script threw: ...' / 'Failed to compile: ...'." + } + }, + "required": ["stdout", "stderr", "exitCode", "statusMessage"] + } + } + }, + "dockerfile": "../Dockerfile" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ec15a3b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.git +*.log +.DS_Store diff --git a/.github/workflows/remote-smoke.yml b/.github/workflows/remote-smoke.yml new file mode 100644 index 0000000..d65548e --- /dev/null +++ b/.github/workflows/remote-smoke.yml @@ -0,0 +1,41 @@ +name: Remote smoke tests + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: remote-smoke-${{ github.ref }} + cancel-in-progress: false + +jobs: + remote-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set up Apify CLI + uses: apify/setup-apify-cli-action@8b19e6a52312e8948b3b10cdb06bd3fa39e8db4a + with: + token: ${{ secrets.APIFY_TOKEN }} + + - name: Run remote smoke tests + run: ./test.sh diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..11b6fe1 --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,46 @@ +name: Typecheck and test + +on: + push: + branches: [master] + pull_request: + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck + + # Token-free unit tests for guard and redirect logic. + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test + + # Real workerd integration tests with a local API mock. + test-integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm run test:integration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a27ff4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +# Generated at container startup by entrypoint.sh (never checked in): +worker/usercode.js +# Compiled from worker/*.ts and tests/*.ts by `pnpm build` (tsconfig.json emits next to source): +worker/runner.js +worker/guard.js +tests/*.js +tests/unit/*.js +tests/integration/*.js +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..88c01ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Build with Node; run with only workerd and required utilities. +FROM node:24-bookworm-slim AS builder +WORKDIR /build +COPY package.json pnpm-lock.yaml tsconfig.json ./ +COPY worker/ ./worker/ +# The binary ships in an optional dependency; compile worker files directly. +RUN corepack enable \ + && pnpm install --frozen-lockfile --ignore-scripts \ + && pnpm exec tsc -p tsconfig.json \ + && BIN="$(node -e "process.stdout.write(require('workerd').default)")" \ + && cp "$BIN" /workerd \ + && chmod +x /workerd + +# Minimal runtime image: workerd, compiled JS, and certificates. +FROM debian:bookworm-slim + +# curl fetches input; jq extracts user code. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl jq \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /workerd /usr/local/bin/workerd + +WORKDIR /app +COPY worker/entrypoint.sh worker/config.capnp ./worker/ +COPY --from=builder /build/worker/runner.js /build/worker/guard.js ./worker/ + +ENTRYPOINT ["sh", "/app/worker/entrypoint.sh"] diff --git a/README.md b/README.md index e1d04ba..7ae9eef 100644 --- a/README.md +++ b/README.md @@ -1 +1,219 @@ # Code Runtime (experimental) + +> ⚠️ **Experimental infrastructure Actor.** It powers **Code Mode** on +> [mcp.apify.com](https://mcp.apify.com) and is normally invoked by the Apify +> MCP Server, not run by hand. Its behaviour and API may change without notice. + +## What it does + +Executes one JS script that an AI agent submits through the Apify MCP +Server, then returns whatever the script printed. + +Lets an agent do many Apify operations in **one call** — search the Store, +run an Actor, read its dataset, filter and aggregate — instead of sending +every intermediate result back through the model. This Actor is the sandbox +that runs that script. + +**Worth it only for bulk work** (measured, A/B eval vs. calling Actor tools +directly): + +| Workload | Verdict | +|---|---| +| Filter/sort/aggregate 50+ dataset records | Modest win — ~20-35% less time, ~20% fewer tokens | +| Fan out over 10+ sub-resources with a sizeable payload each (visit many pages, chain Actors) | Decisive win — ~60% less time, ~75% fewer tokens | +| Under 10 items, no fan-out | **Don't use this Actor** — ~20K-token sandbox overhead isn't paid back | + +## Calling this Actor + +Self-contained — no special MCP-server opt-in required. Any MCP client +already has `search-actors`, `fetch-actor-details`, and `call-actor` as +default tools: + +``` +call-actor({ actor: "apify/code-runtime", input: { code: "..." } }) +``` + +Default `timeoutSecs: 900`, `memoryMbytes: 1024` (`.actor/actor.json`) — +override per call for scripts chaining several long Actor runs (MCP +`call-actor`'s `callOptions.timeout`/`callOptions.memory`, or the API's +`timeout`/`memory`). + +## How it works + +- **One script per run.** Reads `code`, runs it once, writes the result, exits. +- Runs inside a sandboxed [`workerd`](https://github.com/cloudflare/workerd) + V8 isolate — see [Permissions & safety](#permissions--safety) for what's allowed. +- A global **`apify`** object exposes a small, typed subset of the Apify API + — run Actors, read/write datasets and key-value stores — using the + current run's token. +- `console.log`/`console.info` → **stdout**; `console.error`/`console.warn` + → **stderr**, captured separately. +- Call `apify.actor.get({ actorId })` before running an Actor you haven't + checked — don't guess its input schema. +- Log a nested run's `run.id`/`defaultDatasetId`/`defaultKeyValueStoreId` + before processing its output — nothing persists between this Actor's own + runs, but the Actors it started keep theirs. +- Already have a dataset/store ID from an earlier turn? Reuse it — don't + re-run an identical call, it wastes cost. +- Print a small JSON summary, never a full dataset — only `console.log`/ + `console.info` output comes back; a top-level `return` is **not** captured. +- `callAndGetItems` reads the dataset once, right after its (max 60s) wait — + if the child run is still `RUNNING` at that point, `items` may be empty or + partial. Check the returned `run.status` before treating it as final. + +## Input + +```json +{ + "code": "const { items } = await apify.actor.callAndGetItems({ actorId: 'apify/rag-web-browser', input: { query: 'apify' }, limit: 3 });\nconsole.log(items.map((i) => i.metadata?.title).join('\\n'));" +} +``` + +| Field | Type | Description | +|---|---|---| +| `code` | string | The JavaScript script to run (JS only, not transpiled). It receives the `apify` binding and `console`. | +| `maxActorRuns` | number | *Optional.* Caps how many Actor runs the script may start in total; exceeding it throws inside the script. | +| `maxTotalChargeUsd` | number | *Optional.* Execution-level spending budget across all runs the script starts (distinct from a single run's own `maxTotalChargeUsd`); exhausting it throws inside the script. | +| `defaultTimeoutSecs` | number | *Optional.* Default `timeoutSecs` for child runs that don't set their own. | + +## Output + +A single **dataset item**: + +```json +{ "stdout": "Apify: Full-stack web scraping ...\n...", "stderr": "", "exitCode": 0, "statusMessage": "Script completed" } +``` + +| Outcome | `exitCode` | `statusMessage` | +|---|---|---| +| Script returned | `0` | `Script completed` | +| Script threw | `1` | `Script threw: ...` | +| Failed to compile (syntax error) | `1` | `Failed to compile: ...` | +| Run-level timeout / OOM kill | — | item may not exist for this run at all | + +Check `exitCode`/`statusMessage`, not `stderr` content, to detect a failed +script — `stderr` also carries `console.error`/`console.warn` output, so its +presence alone isn't failure. A timeout/OOM kill is signaled by the Actor +run's own status (`SUCCEEDED` vs `FAILED`/`ABORTED`/`TIMED-OUT`), not by this +item's absence. + +## Permissions & safety + +- Sandbox has **no filesystem**; outbound `fetch` (redirects re-validated + per hop) is limited to the Apify API (`*.apify.com`). +- **No imports** — runs without workerd's `nodejs_compat`, so no Node + built-ins (`node:net`, `node:fs`, …) or npm packages. This also removes + `node:net` (a raw-socket path that would bypass the `fetch` allowlist) and + keeps the run token out of `process.env` (undefined here). +- Each run is an isolated, single-use container — nothing persists between runs. +- This closes **direct fetch-based exfil** — it does not close every path to + move data out (e.g. `actor.start({ input })` on an Actor with its own + internet access, or writing to a dataset/key-value store). + +## Recipes + +### Chain Actors (one run's output feeds the next) + +```js +const { items: results } = await apify.actor.callAndGetItems({ + actorId: 'apify/google-search-scraper', input: { queries: 'apify' }, limit: 10, +}); +const startUrls = results.flatMap((r) => r.organicResults ?? []).map((r) => ({ url: r.url })); +const { items: pages } = await apify.actor.callAndGetItems({ + actorId: 'apify/website-content-crawler', input: { startUrls }, +}); +console.log(JSON.stringify(pages.slice(0, 3).map((p) => p.url))); +``` + +### Bounded parallel fan-out + +This Actor's clearest win: run several Actors (or the same Actor over +several inputs) concurrently, then reduce before returning. Chunk it (5–10 +at a time) — an unbounded `Promise.all` can hit your account's +concurrent-run or memory limits. + +```js +const inputs = [{ query: 'a' }, { query: 'b' }, { query: 'c' } /* ... */]; +const CHUNK = 5; +const results = []; +for (let i = 0; i < inputs.length; i += CHUNK) { + const batch = inputs.slice(i, i + CHUNK); + const batchResults = await Promise.all( + batch.map((input) => apify.actor.callAndGetItems({ actorId: 'apify/rag-web-browser', input, limit: 5 })), + ); + results.push(...batchResults.flatMap((r) => r.items)); +} +console.log(JSON.stringify(results.slice(0, 5))); // small summary, not the full dump +``` + +### Read an entire dataset without managing offsets + +`dataset.listItems`/`store` work two ways — `await` for one page, `for +await` to auto-paginate every item (see [the apify binding](#the-apify-binding)): + +```js +// One page — e.g. a quick peek +const { items, count } = await apify.dataset.listItems({ datasetId, limit: 10 }); + +// Every item, however many pages that takes +let matches = 0; +for await (const item of apify.dataset.listItems({ datasetId })) { + if (item.rating >= 4.5) matches++; +} +console.log(`${matches} matching items`); +``` + +### Runs longer than 60s: start, then poll + +`actor.call`'s wait is capped at 60s per request (a REST API limit, not this +Actor's). For a longer-running Actor, start it and poll: + +```js +let run = await apify.actor.start({ actorId, input }); +const TERMINAL = ['SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT']; +while (!TERMINAL.includes(run.status)) { + run = await apify.run.waitForFinish({ runId: run.id, waitForFinishSecs: 60 }); +} +``` + +## The `apify` binding + +Every method takes one options object and returns parsed JSON — except +`store` and `dataset.listItems`, which return a value that's both a +`Promise` (one page) and an `AsyncIterable` (every match/item, +auto-paginated). Full API docs: +[API.md](https://github.com/apify/actor-code-runtime/blob/master/docs/API.md). +(`?` = optional, `= x` = default) + +```js +// Store — GET /v2/store, a top-level Apify API resource (not an Actor method) +apify.store({ search, limit?, offset?, category? }) // → { items, count, offset, limit }; dual Promise/AsyncIterable, see above + +// Actors +apify.actor.get({ actorId }) // → actor +apify.actor.start({ actorId, input?, memoryMbytes?, timeoutSecs?, maxTotalChargeUsd?, maxItems? }) // → run +apify.actor.call({ actorId, ...startOpts, waitForFinishSecs = 60 }) // → run (may be non-terminal READY/RUNNING past the 60s cap — not an error, see Recipes) +apify.actor.callAndGetItems({ actorId, input?, fields?, limit?, ...runOpts }) // → { run, items } (items may be partial if run is still RUNNING — check run.status) + +// Runs +apify.run.get({ runId }) // → run +apify.run.waitForFinish({ runId, waitForFinishSecs = 60 }) // → run (same non-terminal caveat) +apify.run.abort({ runId }) // → run +apify.run.getLog({ runId, limit? }) // → string + +// Datasets +apify.dataset.create({ name? }) // → dataset +apify.dataset.pushItems({ datasetId, items }) // → void +apify.dataset.listItems({ datasetId, fields?, omit?, limit?, offset?, clean?, desc? }) // → { items, count, offset, limit, desc }; dual Promise/AsyncIterable, see above +apify.dataset.inferFields({ datasetId, sample = 5 }) // → { itemCount, fields[] } + +// Key-value stores +apify.keyValueStore.create({ name? }) // → store +apify.keyValueStore.set({ storeId, key, value, contentType? }) // → void +apify.keyValueStore.get({ storeId, key }) // → value | null +apify.keyValueStore.list({ storeId, limit?, exclusiveStartKey? }) // → { items } +``` + +## Learn more + +- Apify MCP Server: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b157831 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,459 @@ +# The `apify` binding — API reference + +Inside a Code Mode script a global **`apify`** object exposes a small, typed +subset of the Apify API, authenticated with the current run's token. This +document describes every method in detail. + +## Conventions + +- **Every method is `async`** — except `dataset.listItems`/`store` (see + below), which are plain functions that return an already-awaitable value; + `await`ing one behaves identically to awaiting a true `async` call. +- **One options object.** Each method takes a single object argument; there are + no positional parameters. +- **Paginated methods are dual-mode.** [`dataset.listItems`](#datasetlistitems--object-count-offset-limit-desc-) + and [`store`](#apifystore--object-count-offset-limit-) return a value that's + both a `Promise` and an `AsyncIterable`: `await` it for one page (`{ items, + count, offset, limit, ... }`); `for await (const item of ...)` it to + auto-paginate through everything, one item at a time. One call, one name, + two ways to consume it — matching the official + [`apify-client`](https://github.com/apify/apify-client-js) SDK's + `PaginatedIterator` convention. There's no separate "iterate" method. +- **`actorId`** accepts either `username/name` (e.g. `apify/rag-web-browser`) or + the Actor's ID. +- **Return values.** The Apify API wraps most responses in a `{ "data": … }` + envelope. These methods **unwrap it for you** and return the inner value; the + linked API page describes that inner `data` shape. Where a method transforms + the response further (extracts an array, parses by content type, infers a + schema, returns plain text), the exact output is spelled out below. +- **`Apify API:`** each method links to its underlying endpoint on + [docs.apify.com/api/v2](https://docs.apify.com/api/v2) so you can inspect the + live request/response schema. +- **Errors.** A non-2xx API response throws an `Error` whose message is + ` failed: `. The one exception is + [`keyValueStore.get`](#keyvaluestoreget--value--null), which returns `null` for a missing key. +- **Network.** Outbound `fetch` from your script is restricted to `apify.com` + and its subdomains. +- **Reuse, don't re-run.** If a prior attempt already logged a nested run's + `defaultDatasetId`/`defaultKeyValueStoreId` (visible in your own earlier + turns), reuse it — do not re-run the same Actor call with identical input. + Re-running wastes the compute/cost of a call that already succeeded. + +--- + +## `apify.store` + +Apify's own API tags this endpoint `Store` — a top-level resource, not an +Actor method — so the binding mirrors that: `apify.store(...)`, not +`apify.actor.store(...)`. + +### `apify.store({ search, limit?, offset?, category? })` → `{ items, count, offset, limit }` + +Search the Apify Store. Dual-mode — see [Conventions](#conventions). + +| Param | Type | Required | Description | +|---|---|---|---| +| `search` | `string` | yes | Full-text search query. | +| `limit` | `number` | no | Page size (`await` mode) / items per page (`for await` mode). | +| `offset` | `number` | no | Starting offset. | +| `category` | `string` | no | Restrict to a Store category. | + +**Output (custom):** one page — `{ items: Actor[], count, offset, limit }` +(`count` is this page's actual item count; `offset`/`limit` echo the request). +**Apify API:** [`GET /v2/store`](https://docs.apify.com/api/v2/store-get) + +```js +// One page +const { items } = await apify.store({ search: 'web scraper', limit: 5 }); +console.log(items.map((a) => `${a.username}/${a.name}`).join('\n')); + +// Every match +const names = []; +for await (const actor of apify.store({ search: 'web scraper', limit: 20 })) { + names.push(`${actor.username}/${actor.name}`); +} +``` + +--- + +## `apify.actor` + +### `actor.get({ actorId })` → `Actor` + +Fetch the full record for one Actor. + +| Param | Type | Required | Description | +|---|---|---|---| +| `actorId` | `string` | yes | `username/name` or Actor ID. | + +**Output:** the Actor object (unwrapped `data`). +**Apify API:** [`GET /v2/acts/{actorId}`](https://docs.apify.com/api/v2/act-get) + +### `actor.start({ actorId, input?, memoryMbytes?, timeoutSecs?, maxTotalChargeUsd?, maxItems? })` → `Run` + +Start an Actor **asynchronously** and return immediately with a run record in +`READY`/`RUNNING` state. Use [`run.waitForFinish`](#runwaitforfinish--run) to block for the result. + +| Param | Type | Required | Description | +|---|---|---|---| +| `actorId` | `string` | yes | `username/name` or Actor ID. | +| `input` | `object` | no | Actor input (defaults to `{}`). | +| `memoryMbytes` | `number` | no | Memory limit for the run (`memory` query param). | +| `timeoutSecs` | `number` | no | Run timeout in seconds (`timeout`). | +| `maxTotalChargeUsd` | `number` | no | Hard cap on the run's cost. | +| `maxItems` | `number` | no | Max dataset items for pay-per-result Actors. | + +**Output:** the Run object (unwrapped `data`). +**Apify API:** [`POST /v2/acts/{actorId}/runs`](https://docs.apify.com/api/v2/act-runs-post) + +### `actor.call({ actorId, input?, waitForFinishSecs?, memoryMbytes?, timeoutSecs?, maxTotalChargeUsd?, maxItems? })` → `Run` + +Start an Actor and **wait** for it to finish (or until `waitForFinishSecs` +elapses), then return the run record. + +| Param | Type | Required | Default | Description | +|---|---|---|---|---| +| `actorId` | `string` | yes | | `username/name` or Actor ID. | +| `input` | `object` | no | `{}` | Actor input. | +| `waitForFinishSecs` | `number` | no | `60` | Seconds to wait (`waitForFinish`). **The Apify API caps a single wait at 60s** — for longer runs use `start()` + a `run.waitForFinish()` loop. | +| `memoryMbytes` | `number` | no | | Memory limit. | +| `timeoutSecs` | `number` | no | | Run timeout. | +| `maxTotalChargeUsd` | `number` | no | | Cost cap. | +| `maxItems` | `number` | no | | Max items (pay-per-result). | + +**Output:** the Run object (unwrapped `data`), exposing `defaultDatasetId` and +`defaultKeyValueStoreId` for reading results. Uses the standard run endpoint +(not `/run-sync`, which returns the output record instead of the run object). +**May return non-terminal** (`status: 'READY'`/`'RUNNING'`) if `waitForFinishSecs` +(capped at 60s by the API) elapses before the run finishes — this is **not** +an error; poll [`run.waitForFinish`](#runwaitforfinish--run) until `status` is +terminal (`SUCCEEDED`/`FAILED`/`ABORTED`/`TIMED-OUT`). +**Apify API:** [`POST /v2/acts/{actorId}/runs`](https://docs.apify.com/api/v2/act-runs-post) + +### `actor.callAndGetItems({ actorId, input?, fields?, limit?, ...runOpts })` → `{ run, items }` + +Convenience wrapper: `actor.call(...)` followed by reading the run's default +dataset via `dataset.listItems`. + +| Param | Type | Required | Description | +|---|---|---|---| +| `actorId` | `string` | yes | `username/name` or Actor ID. | +| `input` | `object` | no | Actor input. | +| `fields` | `string[]` | no | Restrict returned item fields. | +| `limit` | `number` | no | Max items to fetch. | +| `...runOpts` | | no | Any `actor.call` option (`waitForFinishSecs`, `memoryMbytes`, `timeoutSecs`, `maxTotalChargeUsd`, `maxItems`). | + +**Output (custom):** + +```js +{ + run: Run, // the run object, as actor.call returns + items: object[] // items from run.defaultDatasetId +} +``` + +**May return partial results** — if the run is still `RUNNING` when the 60s +wait (`waitForFinishSecs`) elapses, `items` is read from the dataset at that +moment and may be empty or a partial subset of the eventual total. Check the +returned `run.status`; a non-terminal status means `items` is a snapshot, not +the final result. +**Apify API:** [`POST /v2/acts/{actorId}/runs`](https://docs.apify.com/api/v2/act-runs-post) +then [`GET /v2/datasets/{datasetId}/items`](https://docs.apify.com/api/v2/dataset-items-get) + +```js +const { run, items } = await apify.actor.callAndGetItems({ + actorId: 'apify/rag-web-browser', + input: { query: 'apify' }, + limit: 3, +}); +console.log(run.status, items.length); +``` + +--- + +## Execution limits + +Three optional Actor input fields bound a script's ability to start child +Actor runs (`actor.start`/`actor.call`/`actor.callAndGetItems`), independent +of any single call's own `waitForFinishSecs`/`timeoutSecs`/`maxTotalChargeUsd` +(which each bound only that one run): + +| Field | Type | Description | +|---|---|---| +| `maxActorRuns` | `number` | Caps the total number of Actor runs this script may start. Exceeding it throws inside the script. | +| `maxTotalChargeUsd` | `number` | Execution-level spending budget across every run the script starts — distinct from a single run's own `maxTotalChargeUsd`. Each new run's own cap is clamped down so the sum of all committed per-run caps never exceeds this budget; starting a run once the budget is exhausted throws inside the script. | +| `defaultTimeoutSecs` | `number` | Used as a child run's `timeoutSecs` when the script's own `actor.start`/`actor.call`/`actor.callAndGetItems` call didn't specify one. | + +All three are optional — unset means no limit beyond the Apify API's own defaults. + +**`waitForFinishSecs` is not a cost or time limit on the child run.** It only +bounds how long the API request itself waits before returning (see the +non-terminal notes on `actor.call` and `run.waitForFinish` above) — the child +run keeps running, and spending, regardless. `defaultTimeoutSecs` and +`maxTotalChargeUsd` above are what actually bound a run's duration and cost. + +--- + +## `apify.run` + +### `run.get({ runId })` → `Run` + +Fetch the current run record (status, stats, default storage IDs). + +| Param | Type | Required | Description | +|---|---|---|---| +| `runId` | `string` | yes | The run ID. | + +**Output:** the Run object (unwrapped `data`). +**Apify API:** [`GET /v2/actor-runs/{runId}`](https://docs.apify.com/api/v2/actor-run-get) + +### `run.waitForFinish({ runId, waitForFinishSecs? })` → `Run` + +Block until the run terminates or `waitForFinishSecs` elapses, whichever comes +first, then return the run record. + +| Param | Type | Required | Default | Description | +|---|---|---|---|---| +| `runId` | `string` | yes | | The run ID. | +| `waitForFinishSecs` | `number` | no | `60` | Seconds to wait (`waitForFinish`). **Capped at 60s by the API**; poll in a loop for longer runs. | + +**Output:** the Run object (unwrapped `data`). **May return non-terminal** +(`status: 'READY'`/`'RUNNING'`) if the cap elapses first — this is **not** an +error, it means keep polling; check `status` against the terminal set +(`SUCCEEDED`/`FAILED`/`ABORTED`/`TIMED-OUT`) before treating any other value +as a failure. +**Apify API:** [`GET /v2/actor-runs/{runId}`](https://docs.apify.com/api/v2/actor-run-get) + +### `run.abort({ runId })` → `Run` + +Abort a running run. Returns the run record (status transitions to `ABORTING`). +Aborting a finished run is an API error. + +| Param | Type | Required | Description | +|---|---|---|---| +| `runId` | `string` | yes | The run ID. | + +**Output:** the Run object (unwrapped `data`). +**Apify API:** [`POST /v2/actor-runs/{runId}/abort`](https://docs.apify.com/api/v2/act-run-abort-post) + +### `run.getLog({ runId, limit? })` → `string` + +Return the run's log as text. + +| Param | Type | Required | Description | +|---|---|---|---| +| `runId` | `string` | yes | The run ID. | +| `limit` | `number` | no | If set, return only the **last** `limit` characters (client-side tail; the full log is fetched). | + +**Output (custom):** the raw log **text** (not JSON). With `limit`, the last +`limit` characters. +**Apify API:** [`GET /v2/logs/{runId}`](https://docs.apify.com/api/v2/log-get) + +--- + +## `apify.dataset` + +### `dataset.create({ name? })` → `Dataset` + +Create a dataset and return its record (use `.id` for subsequent calls). + +| Param | Type | Required | Description | +|---|---|---|---| +| `name` | `string` | no | Named (persistent) dataset; omit for an unnamed (temporary) one. | + +**Output:** the Dataset object (unwrapped `data`). +**Apify API:** [`POST /v2/datasets`](https://docs.apify.com/api/v2/datasets-post) + +### `dataset.pushItems({ datasetId, items })` → `void` + +Append one or more items to a dataset. + +| Param | Type | Required | Description | +|---|---|---|---| +| `datasetId` | `string` | yes | Target dataset ID. | +| `items` | `object \| object[]` | yes | A single item or an array of items. | + +**Output:** none (resolves once the items are stored). +**Apify API:** [`POST /v2/datasets/{datasetId}/items`](https://docs.apify.com/api/v2/dataset-items-post) + +```js +const ds = await apify.dataset.create(); +await apify.dataset.pushItems({ datasetId: ds.id, items: [{ a: 1 }, { a: 2 }] }); +const { items } = await apify.dataset.listItems({ datasetId: ds.id }); +``` + +### `dataset.listItems({ datasetId, fields?, omit?, limit?, offset?, clean?, desc? })` → `{ items, count, offset, limit, desc }` + +Read the dataset. Dual-mode — see [Conventions](#conventions): `await` for one +page, `for await` to auto-paginate through everything (replaces what used to +be a separate `iterate()` method — one name, one method, both jobs). + +| Param | Type | Required | Description | +|---|---|---|---| +| `datasetId` | `string` | yes | Dataset ID. | +| `fields` | `string[]` | no | Only include these fields (joined into `fields`). | +| `omit` | `string[]` | no | Exclude these fields. | +| `limit` | `number` | no | Page size (`await` mode) / items fetched per page (`for await` mode). Omit for the API's own default (effectively unbounded — a single `await` then returns everything in one page). | +| `offset` | `number` | no | Starting offset. Default `0`. | +| `clean` | `boolean` | no | Skip empty items / hidden fields (`clean=1`). | +| `desc` | `boolean` | no | Reverse (newest first, `desc=1`). | + +**Output (custom):** +- `await apify.dataset.listItems({...})` → one page: `{ items: object[], count, + offset, limit, desc }`. `offset`/`limit`/`desc` echo what the API actually + applied (read from its `x-apify-pagination-*` response headers, not just + the request); `count` is this page's actual item count. **No `total`** — + the API's `x-apify-pagination-total` header is unreliable for + freshly-created datasets (eventually consistent); use + [`inferFields`](#datasetinferfields--schema) for an approximate count. +- `for await (const item of apify.dataset.listItems({...}))` → every item in + the dataset, one at a time, paging internally. Stops when a page comes back + shorter than requested (the natural end-of-data signal). + +**Apify API:** [`GET /v2/datasets/{datasetId}/items`](https://docs.apify.com/api/v2/dataset-items-get) (paged internally for `for await`) + +```js +// One page +const { items, count } = await apify.dataset.listItems({ datasetId, limit: 100 }); +console.log(`${count} items on this page`); + +// Every item +let total = 0; +for await (const item of apify.dataset.listItems({ datasetId })) total++; +console.log('total items:', total); +``` + +### `dataset.inferFields({ datasetId, sample? })` → `Schema` + +Infer a lightweight schema from a sample of items (Apify has no schema endpoint). + +| Param | Type | Required | Default | Description | +|---|---|---|---|---| +| `datasetId` | `string` | yes | | Dataset ID. | +| `sample` | `number` | no | `5` | Number of items to inspect. | + +**Output (custom):** + +```js +{ + itemCount, // number | undefined — from the dataset metadata (eventually consistent) + sampleSize, // number of items actually inspected + fields: [ // one entry per field seen across the sample + { + name, // field name + types, // string[], e.g. ['string'] or ['number','null'] + nullable // boolean — true if any sampled value was null + } + ] +} +``` + +**Apify API:** [`GET /v2/datasets/{datasetId}`](https://docs.apify.com/api/v2/dataset-get) +(for `itemCount`) + [`GET /v2/datasets/{datasetId}/items`](https://docs.apify.com/api/v2/dataset-items-get) (the sample) + +--- + +## `apify.keyValueStore` + +### `keyValueStore.create({ name? })` → `KeyValueStore` + +Create a key-value store and return its record. + +| Param | Type | Required | Description | +|---|---|---|---| +| `name` | `string` | no | Named (persistent) store; omit for an unnamed (temporary) one. | + +**Output:** the key-value store's record object (unwrapped `data`). +**Apify API:** [`POST /v2/key-value-stores`](https://docs.apify.com/api/v2/key-value-stores-post) + +### `keyValueStore.set({ storeId, key, value, contentType? })` → `void` + +Write a record. The content type is inferred from `value`: + +| `value` type | Stored as | +|---|---| +| `object` | `application/json; charset=utf-8` | +| `string` | `text/plain; charset=utf-8` | +| `Uint8Array` / `ArrayBuffer` | `application/octet-stream` | + +| Param | Type | Required | Description | +|---|---|---|---| +| `storeId` | `string` | yes | Store ID. | +| `key` | `string` | yes | Record key. | +| `value` | `object \| string \| Uint8Array \| ArrayBuffer` | yes | Value to store. | +| `contentType` | `string` | no | Override the inferred content type. | + +**Output:** none (resolves once the record is stored). +**Apify API:** [`PUT /v2/key-value-stores/{storeId}/records/{key}`](https://docs.apify.com/api/v2/key-value-store-record-put) + +### `keyValueStore.get({ storeId, key })` → `value` \| `null` + +Read a record. + +| Param | Type | Required | Description | +|---|---|---|---| +| `storeId` | `string` | yes | Store ID. | +| `key` | `string` | yes | Record key. | + +**Output (custom):** the value, typed by the stored content type — + +- `application/json` → parsed **object** +- `text/*` → **string** +- anything else → **`Uint8Array`** + +Returns **`null`** when the key does not exist (404) instead of throwing, so you +can do lookup-or-default without a `try/catch`. +**Apify API:** [`GET /v2/key-value-stores/{storeId}/records/{key}`](https://docs.apify.com/api/v2/key-value-store-record-get) + +```js +const kv = await apify.keyValueStore.create(); +await apify.keyValueStore.set({ storeId: kv.id, key: 'state', value: { seen: [] } }); +const state = await apify.keyValueStore.get({ storeId: kv.id, key: 'state' }); // → { seen: [] } +const missing = await apify.keyValueStore.get({ storeId: kv.id, key: 'nope' }); // → null +``` + +### `keyValueStore.list({ storeId, limit?, exclusiveStartKey? })` → `{ items, … }` + +List keys in a store. + +| Param | Type | Required | Description | +|---|---|---|---| +| `storeId` | `string` | yes | Store ID. | +| `limit` | `number` | no | Max keys to return. | +| `exclusiveStartKey` | `string` | no | Continue listing after this key (pagination). | + +**Output:** the unwrapped `data`: `{ items: [{ key, size }], count, limit, +isTruncated, exclusiveStartKey, nextExclusiveStartKey }`. +**Apify API:** [`GET /v2/key-value-stores/{storeId}/keys`](https://docs.apify.com/api/v2/key-value-store-keys-get) + +--- + +## `console` + +`console` is captured, not printed live: + +| Method | Stream | +|---|---| +| `console.log`, `console.info` | **stdout** | +| `console.error`, `console.warn` | **stderr** | + +Non-string arguments are `JSON.stringify`'d. When the script finishes, both +streams and the script's exit status are written to the run's default dataset as +a single item: + +```json +{ "stdout": "...", "stderr": "...", "exitCode": 0 } +``` + +`exitCode` is the script's effective exit status: `0` when it returns normally, +`1` when it throws. It is distinct from the Actor run's status — see below. + +## Error handling + +- A non-2xx API response throws `Error: failed: `. +- If your script throws, the error (stack/message) is appended to **stderr**, + `exitCode` is set to `1`, and the run still **succeeds** with whatever was + printed beforehand — so failures are observable in the output rather than + crashing the run. Check `exitCode` (not `stderr`) to detect a failed script: + `stderr` may be non-empty from ordinary `console.error` / `console.warn` + logging even on a successful run. diff --git a/package.json b/package.json new file mode 100644 index 0000000..9008cd2 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "code-runtime", + "version": "0.1.0", + "description": "workerd as a normal (per-run) Apify Actor: one static worker, booted once per run, runs the submitted script and exits.", + "private": true, + "type": "module", + "packageManager": "pnpm@11.1.3", + "dependencies": { + "workerd": "1.20260402.1" + }, + "engines": { + "node": ">=24" + }, + "scripts": { + "build": "tsc -p tsconfig.json && sed -i '/^export {};$/d' tests/*.js", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.integration.json", + "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts" + }, + "devDependencies": { + "@types/node": "24.13.3", + "typescript": "6.0.3", + "vitest": "4.1.10" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8366a92 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,844 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + workerd: + specifier: 1.20260402.1 + version: 1.20260402.1 + devDependencies: + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)) + +packages: + + '@cloudflare/workerd-darwin-64@1.20260402.1': + resolution: {integrity: sha512-/kyyZ5HjOPT202Vsw3P+vICsVulldC9ym+W5UyKl9dufxfHgcdeT4EYtT+tkk+3SlkB1RmoVxgr3VRlqP2ojjw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260402.1': + resolution: {integrity: sha512-nr+AgUonmepiuD4utn4KQl2fIn/aDuWEO+/B2fzjMnjLSgLpN1IQADUW1uD4FVJdd0ss9ugK1dztFJJWpImCaQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260402.1': + resolution: {integrity: sha512-0vJj0pO6ARpCmvyLgbQk204dogL72geEyC+rOnkFgwcDJI4e8oxKrTQZWjPHra3BHUknEomUd37i3K4L4JfU4w==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260402.1': + resolution: {integrity: sha512-aRsxuv1bmwkkX4sG8igutkwVHgsJ1I6mH4/u/1Jfhpb7Bj3xr9p9N2fjDwax+Dhy7xt2tC8TS/ZWLeFFjK2NMA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260402.1': + resolution: {integrity: sha512-aFKYAuIYTPsuWyHxv39yOFi9bmIvAsIuNmHGYkYltHZUoN4by6tJVCOQUp6PDlZO7TjHSD2XjjM8JBwK/D6tVA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} + + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260402.1: + resolution: {integrity: sha512-Cg+OUlukdcCHrTTg0MBCIMFRE6XO3yGVGiWCnJPvfffy2Ga2girrEq3qF/YlHSTmbIyEE5ebCFxBYYYZueQ/Mg==} + engines: {node: '>=16'} + hasBin: true + +snapshots: + + '@cloudflare/workerd-darwin-64@1.20260402.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260402.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260402.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260402.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260402.1': + optional: true + + '@emnapi/core@2.0.0-alpha.3': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.142.0': {} + + '@rolldown/binding-android-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-x64@1.2.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.1': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@24.13.3) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.1: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + vite@8.2.0(@types/node@24.13.3): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@24.13.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260402.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260402.1 + '@cloudflare/workerd-darwin-arm64': 1.20260402.1 + '@cloudflare/workerd-linux-64': 1.20260402.1 + '@cloudflare/workerd-linux-arm64': 1.20260402.1 + '@cloudflare/workerd-windows-64': 1.20260402.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..797a5c3 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + workerd: true diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..99bde2c --- /dev/null +++ b/test.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Build, deploy, and run each remote probe. +# Usage: ./test.sh +set -eu + +cd "$(dirname "$0")" + +# Build probes from tests/*.ts. +PROBES="tests/binding-smoke.js tests/sandbox-isolation.js" +APIFY_CMD="${APIFY_CMD:-apify}" + +if [ "$APIFY_CMD" = "apify" ] && ! command -v apify >/dev/null 2>&1; then + echo "apify CLI not found" >&2 + exit 1 +fi +command -v jq >/dev/null 2>&1 || { echo "jq not found" >&2; exit 1; } + +run_apify() { + $APIFY_CMD "$@" +} + +echo "==> pnpm build" +CI=true pnpm build + +input_json="$(mktemp)" +trap 'rm -f "$input_json"' EXIT + +echo "==> apify push" +run_apify push --force + +failed=0 +for probe in $PROBES; do + echo "==> apify call: ${probe}" + jq -n --arg code "$(cat "$probe")" '{ code: $code }' > "$input_json" + output="$(run_apify call -f "$input_json" -o 2>&1)" + echo "$output" + if printf '%s' "$output" | grep -q 'ALL_TESTS_PASSED'; then + echo "==> ${probe} passed" + else + echo "==> ${probe} FAILED" >&2 + failed=1 + fi +done + +[ "$failed" -eq 0 ] || { echo "==> some probes FAILED" >&2; exit 1; } +echo "==> all probes passed" + +# Regression probe for module-scope capability theft. +echo "==> apify call: tests/fixtures/realfetch-escape.js (regression: guard.js capability theft)" +jq -n --arg code "$(cat tests/fixtures/realfetch-escape.js)" '{ code: $code }' > "$input_json" +if run_apify call -f "$input_json" -o; then + echo "==> realfetch-escape passed (run succeeded — module-scope steal attempt found nothing to steal)" +else + echo "==> realfetch-escape FAILED (run crashed — capability-theft regression, see guard.ts/runner.ts/config.capnp's INTERNAL_API binding)" >&2 + failed=1 +fi + +[ "$failed" -eq 0 ] || { echo "==> some probes FAILED" >&2; exit 1; } +echo "==> all probes (including regressions) passed" diff --git a/tests/binding-smoke.ts b/tests/binding-smoke.ts new file mode 100644 index 0000000..299113f --- /dev/null +++ b/tests/binding-smoke.ts @@ -0,0 +1,152 @@ +// Actor probe covering every exposed binding method. +// `export {}` enables top-level await; build strips it before wrapping code. +export {}; + +const results: boolean[] = []; +async function check(name: string, fn: () => Promise): Promise { + try { + const out = await fn(); + console.log(`PASS ${name}: ${out ?? ''}`); + results.push(true); + } catch (e) { + console.error(`FAIL ${name}: ${(e as Error).message}`); + results.push(false); + } +} + +const ACTOR = 'apify/hello-world'; +const [ACTOR_USERNAME, ACTOR_NAME] = ACTOR.split('/'); + +// All run statuses returned by the Apify API. +const RUN_STATUSES = new Set(['READY', 'RUNNING', 'SUCCEEDED', 'FAILED', 'ABORTING', 'ABORTED', 'TIMING-OUT', 'TIMED-OUT']); + +await check('store', async () => { + const page = await apify.store({ search: 'hello world', limit: 3 }); + if (!Array.isArray(page.items) || page.items.length === 0) throw new Error(`expected non-empty items array, got ${JSON.stringify(page.items)}`); + if (typeof page.items[0].name !== 'string') throw new Error(`expected item.name string, got ${JSON.stringify(page.items[0])}`); + return `${page.count} actors`; +}); +await check('store (for await)', async () => { + let n = 0; + for await (const _ of apify.store({ search: 'hello world', limit: 3 })) n++; + if (n === 0) throw new Error('expected at least 1 actor iterated, got 0'); + return `${n} actors iterated`; +}); +await check('actor.get', async () => { + const d = await apify.actor.get({ actorId: ACTOR }); + if (d.username !== ACTOR_USERNAME || d.name !== ACTOR_NAME) { + throw new Error(`actor.get returned username=${d.username} name=${d.name}, expected ${ACTOR_USERNAME}/${ACTOR_NAME}`); + } + return `${d.username}/${d.name}`; +}); + +let datasetId = ''; +await check('dataset.create', async () => { + datasetId = (await apify.dataset.create()).id as string; + if (!datasetId) throw new Error('dataset.create returned no id'); + return datasetId; +}); +await check('dataset.pushItems', async () => { + await apify.dataset.pushItems({ datasetId, items: [{ a: 1, b: 'x' }, { a: 2, b: 'y' }] }); + return '2 pushed'; +}); +await check('dataset.listItems', async () => { + const page = await apify.dataset.listItems({ datasetId }); + if (page.count !== 2 || page.items[0]?.a !== 1 || page.items[0]?.b !== 'x' || page.items[1]?.a !== 2 || page.items[1]?.b !== 'y') { + throw new Error(`expected 2 pushed items round-tripped, got ${JSON.stringify(page.items)}`); + } + return `${page.count} items, offset=${page.offset}, limit=${page.limit}`; +}); +await check('dataset.inferFields', async () => { + const s = await apify.dataset.inferFields({ datasetId }); + const fieldNames = s.fields.map((f) => f.name).sort(); + if (fieldNames.join(',') !== 'a,b') throw new Error(`expected fields a,b, got ${fieldNames.join(',')}`); + const a = s.fields.find((f) => f.name === 'a'); + const b = s.fields.find((f) => f.name === 'b'); + if (!a?.types.includes('number')) throw new Error(`expected field a to include type number, got ${JSON.stringify(a)}`); + if (!b?.types.includes('string')) throw new Error(`expected field b to include type string, got ${JSON.stringify(b)}`); + return `itemCount=${s.itemCount} fields=${fieldNames.join(',')}`; +}); +await check('dataset.listItems (for await)', async () => { + let n = 0; + for await (const _ of apify.dataset.listItems({ datasetId, limit: 1 })) n++; + if (n !== 2) throw new Error(`expected 2 pushed items iterated, got ${n}`); + return `${n} iterated`; +}); + +let storeId = ''; +await check('keyValueStore.create', async () => { + storeId = (await apify.keyValueStore.create()).id as string; + return storeId; +}); +await check('keyValueStore.set', async () => { + await apify.keyValueStore.set({ storeId, key: 'obj', value: { hello: 'world' } }); + await apify.keyValueStore.set({ storeId, key: 'txt', value: 'plain' }); + return 'set obj + txt'; +}); +await check('keyValueStore.get', async () => { + const obj = await apify.keyValueStore.get({ storeId, key: 'obj' }) as { hello?: unknown } | null; + if (obj?.hello !== 'world') throw new Error(`kvs.get returned ${JSON.stringify(obj)}`); + const txt = await apify.keyValueStore.get({ storeId, key: 'txt' }); + if (txt !== 'plain') throw new Error(`kvs.get txt returned ${JSON.stringify(txt)}`); + const missing = await apify.keyValueStore.get({ storeId, key: 'nope' }); + if (missing !== null) throw new Error(`kvs.get missing key returned ${JSON.stringify(missing)}, expected null`); + return `obj.hello=${obj.hello} txt=${txt} missing=${missing}`; +}); +await check('keyValueStore.list', async () => { + const l = await apify.keyValueStore.list({ storeId }) as { items: { key: string }[] }; + const keys = l.items.map((i) => i.key); + if (!keys.includes('obj') || !keys.includes('txt')) throw new Error(`expected keys obj+txt in list, got ${JSON.stringify(keys)}`); + return `${l.items.length} keys`; +}); + +let runId = ''; +await check('actor.start', async () => { + const run = await apify.actor.start({ actorId: ACTOR }); + runId = run.id as string; + if (!runId) throw new Error('actor.start returned no run id'); + if (!RUN_STATUSES.has(run.status)) throw new Error(`actor.start returned unexpected status: ${JSON.stringify(run.status)}`); + return `runId=${runId} status=${run.status}`; +}); +await check('run.get', async () => { + const run = await apify.run.get({ runId }); + if (run.id !== runId) throw new Error(`run.get returned id=${run.id}, expected ${runId}`); + if (!RUN_STATUSES.has(run.status)) throw new Error(`run.get returned unexpected status: ${JSON.stringify(run.status)}`); + return `status=${run.status}`; +}); +await check('run.waitForFinish', async () => { + const run = await apify.run.waitForFinish({ runId, waitForFinishSecs: 60 }); + if (!RUN_STATUSES.has(run.status)) throw new Error(`run.waitForFinish returned unexpected status: ${JSON.stringify(run.status)}`); + return `status=${run.status}`; +}); +await check('run.getLog', async () => { + const log = await apify.run.getLog({ runId, limit: 200 }); + if (typeof log !== 'string' || log.length === 0) throw new Error(`run.getLog returned ${JSON.stringify(log)}`); + return `${log.length} chars`; +}); + +await check('actor.call', async () => { + const run = await apify.actor.call({ actorId: ACTOR, waitForFinishSecs: 60 }); + if (!RUN_STATUSES.has(run.status)) throw new Error(`actor.call returned unexpected status: ${JSON.stringify(run.status)}`); + return `status=${run.status}`; +}); +await check('actor.callAndGetItems', async () => { + const { run, items } = await apify.actor.callAndGetItems({ actorId: ACTOR, limit: 5, waitForFinishSecs: 60 }); + if (!RUN_STATUSES.has(run.status)) throw new Error(`actor.callAndGetItems returned unexpected status: ${JSON.stringify(run.status)}`); + if (!Array.isArray(items)) throw new Error(`actor.callAndGetItems returned non-array items: ${JSON.stringify(items)}`); + return `status=${run.status} items=${items.length}`; +}); + +await check('run.abort', async () => { + const run = await apify.actor.start({ actorId: ACTOR }); + const aborted = await apify.run.abort({ runId: run.id as string }); + if (aborted.status !== 'ABORTING' && aborted.status !== 'ABORTED') { + throw new Error(`run.abort returned unexpected status: ${JSON.stringify(aborted.status)}`); + } + return `status=${aborted.status}`; +}); + +const passed = results.filter(Boolean).length; +console.log(`\n=== SUMMARY: ${passed}/${results.length} passed ===`); +if (passed === results.length) console.log('ALL_TESTS_PASSED'); +else console.error(`SOME_TESTS_FAILED (${results.length - passed} failed)`); diff --git a/tests/fixtures/realfetch-escape.js b/tests/fixtures/realfetch-escape.js new file mode 100644 index 0000000..4ec086a --- /dev/null +++ b/tests/fixtures/realfetch-escape.js @@ -0,0 +1,6 @@ +// Capability-theft regression: escaped module code cannot import an unrestricted fetch. +// The unbalanced brace intentionally escapes the generated run() wrapper. +// Genuine top-level await delays runner.ts evaluation until this probe runs. +} +globalThis.__stolenRealFetch = (await import('./guard.js')).claimRealFetch(); +;{ diff --git a/tests/globals.d.ts b/tests/globals.d.ts new file mode 100644 index 0000000..ef51ea9 --- /dev/null +++ b/tests/globals.d.ts @@ -0,0 +1,10 @@ +// Ambient types for probes compiled into run(apify, console) bodies. +import type { ApifyBinding } from '../worker/runner.js'; + +declare global { + const apify: ApifyBinding; + + // Declared for typeof checks; workerd has no process or require globals. + const process: unknown; + const require: unknown; +} diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts new file mode 100644 index 0000000..ef5dd04 --- /dev/null +++ b/tests/integration/harness.ts @@ -0,0 +1,186 @@ +// Integration harness for real workerd, module wiring, and env bindings. +// Run `pnpm build` first. +import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer, type Server } from 'node:http'; +import { mkdtempSync, writeFileSync, readFileSync, cpSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; + +const WORKERD_STARTUP_TIMEOUT_MS = 5_000; +const WORKERD_STARTUP_POLL_INTERVAL_MS = 100; +const WORKERD_EXIT_TIMEOUT_MS = 2_000; + +function workerdBinaryPath(): string { + // Match Dockerfile's workerd binary resolution. + const require = createRequire(import.meta.url); + return require('workerd').default; +} + +// Ask the OS for a free port instead of guessing. +async function reserveEphemeralPort(): Promise { + const probe = createServer(); + await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve)); + const address = probe.address(); + if (!address || typeof address === 'string') throw new Error('failed to reserve an ephemeral port'); + await new Promise((resolve) => probe.close(() => resolve())); + return address.port; +} + +// Minimal API stand-in for end-to-end runtime behavior. +export interface MockApi { + server: Server; + port: number; + requests: { method: string; path: string; body: string }[]; + /** Fail the next run-create request once. */ + failNextRunCreate: (status: number, body: string) => void; + close: () => Promise; +} + +export async function startMockApi(): Promise { + const requests: MockApi['requests'] = []; + let pendingRunCreateFailure: { status: number; body: string } | null = null; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + // Match paths without query strings. + const pathname = new URL(req.url ?? '/', 'http://mock-api.internal').pathname; + requests.push({ method: req.method ?? '', path: req.url ?? '', body }); + res.setHeader('content-type', 'application/json'); + if (req.method === 'POST' && pathname.startsWith('/v2/datasets/') && pathname.endsWith('/items')) { + res.writeHead(201); + res.end('{}'); + } else if (req.method === 'POST' && pathname.startsWith('/v2/acts/') && pathname.endsWith('/runs')) { + if (pendingRunCreateFailure) { + const { status, body: failureBody } = pendingRunCreateFailure; + pendingRunCreateFailure = null; + res.writeHead(status); + res.end(failureBody); + return; + } + res.writeHead(201); + res.end(JSON.stringify({ data: { id: `run-${requests.length}`, status: 'READY', defaultDatasetId: 'ds1' } })); + } else if (req.method === 'GET' && pathname.startsWith('/v2/datasets/') && pathname.endsWith('/items')) { + res.writeHead(200); + res.end('[]'); + } else { + res.writeHead(200); + res.end(JSON.stringify({ data: { id: 'x', status: 'SUCCEEDED' } })); + } + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('mock API failed to bind a port'); + return { + server, + port: address.port, + requests, + failNextRunCreate: (status, body) => { pendingRunCreateFailure = { status, body }; }, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +export interface RunOptions { + /** Input fields beyond `code`. */ + inputFields?: Record; + /** Configure the mock before workerd starts. */ + beforeStart?: (mockApi: MockApi) => void; +} + +export interface RunResult { + /** Pushed output, or null if output was never written. */ + pushedItem: Record | null; + /** Whether workerd served /health. */ + startedCleanly: boolean; + /** Workerd stderr. */ + stderr: string; + mockApi: MockApi; +} + +// Boot workerd with wrapped code, run once, and release all resources. +export async function runScript(code: string, options: RunOptions = {}): Promise { + const mockApi = await startMockApi(); + try { + options.beforeStart?.(mockApi); + const workDir = mkdtempSync(join(tmpdir(), 'code-runtime-it-')); + try { + return await runInWorkDir(code, options, mockApi, workDir); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + } finally { + await mockApi.close(); + } +} + +async function runInWorkDir(code: string, options: RunOptions, mockApi: MockApi, workDir: string): Promise { + const repoRoot = join(import.meta.dirname, '..', '..'); + + cpSync(join(repoRoot, 'worker', 'runner.js'), join(workDir, 'runner.js')); + cpSync(join(repoRoot, 'worker', 'guard.js'), join(workDir, 'guard.js')); + writeFileSync(join(workDir, 'usercode.js'), `export async function run(apify, console) {\n${code}\n}\n`); + + const port = await reserveEphemeralPort(); + // Replace both config placeholders. + const configTemplate = readFileSync(join(repoRoot, 'worker', 'config.capnp'), 'utf8'); + writeFileSync(join(workDir, 'config.capnp'), configTemplate.replaceAll('__PORT__', String(port))); + + const inputFields = options.inputFields ?? {}; + const child: ChildProcess = spawn(workerdBinaryPath(), ['serve', '--experimental', join(workDir, 'config.capnp')], { + env: { + ...process.env, + APIFY_TOKEN: 'fake-token', + ACTOR_DEFAULT_DATASET_ID: 'ds-default', + APIFY_API_BASE_URL: `http://127.0.0.1:${mockApi.port}`, + APIFY_META_ORIGIN: '', + CODE_RUNTIME_MAX_ACTOR_RUNS: inputFields.maxActorRuns !== undefined ? String(inputFields.maxActorRuns) : '', + CODE_RUNTIME_MAX_TOTAL_CHARGE_USD: inputFields.maxTotalChargeUsd !== undefined ? String(inputFields.maxTotalChargeUsd) : '', + CODE_RUNTIME_DEFAULT_TIMEOUT_SECS: inputFields.defaultTimeoutSecs !== undefined ? String(inputFields.defaultTimeoutSecs) : '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + + const deadline = Date.now() + WORKERD_STARTUP_TIMEOUT_MS; + let startedCleanly = false; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://127.0.0.1:${port}/health`); + if (res.ok) { startedCleanly = true; break; } + } catch { /* retry until timeout */ } + if (child.exitCode !== null) break; + await new Promise((resolve) => setTimeout(resolve, WORKERD_STARTUP_POLL_INTERVAL_MS)); + } + + if (startedCleanly) { + try { + await fetch(`http://127.0.0.1:${port}/run`, { method: 'POST' }); + } catch { /* assert worker failures from returned state */ } + } + + const pushRequest = mockApi.requests.find((r) => { + const pathname = new URL(r.path, 'http://mock-api.internal').pathname; + return r.method === 'POST' && pathname === '/v2/datasets/ds-default/items'; + }); + return { + pushedItem: pushRequest ? JSON.parse(pushRequest.body) : null, + startedCleanly, + stderr, + mockApi, + }; + } finally { + child.kill(); + // Ensure slow workerd processes cannot hold ports between tests. + if (child.exitCode === null) { + await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve())), + new Promise((resolve) => setTimeout(() => { child.kill('SIGKILL'); resolve(); }, WORKERD_EXIT_TIMEOUT_MS)), + ]); + } + } +} diff --git a/tests/integration/workerd-e2e.test.ts b/tests/integration/workerd-e2e.test.ts new file mode 100644 index 0000000..ebec9c3 --- /dev/null +++ b/tests/integration/workerd-e2e.test.ts @@ -0,0 +1,134 @@ +// End-to-end tests for real workerd, module wiring, and env bindings. +// Run `pnpm build` first; these tests spawn real processes and ports. +import { describe, expect, it } from 'vitest'; +import { runScript } from './harness.js'; + +describe('guard.js is actually enforced (not just correct in isolation)', () => { + it('blocks a disallowed host for an ordinary, non-escaping script', async () => { + const result = await runScript(` + try { + const r = await fetch('http://example.com/'); + console.log('LEAK status=' + r.status); + } catch (e) { + console.log('blocked: ' + e.message); + } + `); + expect(result.startedCleanly).toBe(true); + expect(result.pushedItem?.stdout).toMatch(/^blocked: Blocked fetch/); + expect(result.pushedItem?.stdout).not.toMatch(/LEAK/); + }); + + + it('blocks WebSocket construction', async () => { + const result = await runScript(` + try { + new WebSocket('ws://example.com/'); + console.log('LEAK: constructed'); + } catch (e) { + console.log('blocked: ' + e.message); + } + `); + expect(result.pushedItem?.stdout).toMatch(/^blocked: Blocked WebSocket/); + }); + + it('the PR #1 capability-theft PoC fails closed with no capability leak', async () => { + // Module-scope capability-theft regression. + const result = await runScript(` +} +globalThis.__stolenRealFetch = (await import('./guard.js')).claimRealFetch(); +;{ + `); + expect(result.startedCleanly).toBe(false); + expect(result.stderr).toMatch(/claimRealFetch is not a function/); + }); +}); + +describe('execution-limit safeguards fire under real (including concurrent) use', () => { + it('maxActorRuns blocks a run past the configured limit (sequential)', async () => { + const result = await runScript(` + await apify.actor.start({ actorId: 'apify/hello-world' }); + try { + await apify.actor.start({ actorId: 'apify/hello-world' }); + console.log('LEAK: second run started'); + } catch (e) { + console.log('blocked: ' + e.message); + } + `, { inputFields: { maxActorRuns: 1 } }); + expect(result.pushedItem?.stdout).toMatch(/^blocked: Blocked actor run/); + }); + + it('maxActorRuns blocks a run past the limit even when calls race concurrently', async () => { + const result = await runScript(` + const results = await Promise.allSettled( + Array.from({ length: 5 }, () => apify.actor.start({ actorId: 'apify/hello-world' })), + ); + const started = results.filter((r) => r.status === 'fulfilled').length; + const blocked = results.filter((r) => r.status === 'rejected').length; + console.log('started=' + started + ' blocked=' + blocked); + `, { inputFields: { maxActorRuns: 1 } }); + expect(result.pushedItem?.stdout).toBe('started=1 blocked=4'); + }); + + it('maxTotalChargeUsd blocks a run once the execution budget is exhausted', async () => { + const result = await runScript(` + await apify.actor.start({ actorId: 'apify/hello-world', maxTotalChargeUsd: 5 }); + try { + await apify.actor.start({ actorId: 'apify/hello-world' }); + console.log('LEAK: second run started'); + } catch (e) { + console.log('blocked: ' + e.message); + } + `, { inputFields: { maxTotalChargeUsd: 5 } }); + expect(result.pushedItem?.stdout).toMatch(/^blocked: Blocked actor run: execution spending budget/); + }); + + it('defaultTimeoutSecs is applied to a run that does not specify its own timeoutSecs', async () => { + const result = await runScript(` + await apify.actor.start({ actorId: 'apify/hello-world' }); + `, { inputFields: { defaultTimeoutSecs: 42 } }); + const runCreateRequest = result.mockApi.requests.find((r) => r.method === 'POST' && r.path.includes('/acts/')); + expect(runCreateRequest?.path).toMatch(/timeout=42/); + }); + + it('a rejected actor.start() releases its reservation (rollback actually fires)', async () => { + const result = await runScript(` + let firstFailed = false; + try { + await apify.actor.start({ actorId: 'apify/hello-world' }); + } catch (e) { + firstFailed = true; + } + let secondSucceeded = false; + try { + await apify.actor.start({ actorId: 'apify/hello-world' }); + secondSucceeded = true; + } catch (e) { /* expected only if rollback fails */ } + console.log('firstFailed=' + firstFailed + ' secondSucceeded=' + secondSucceeded); + `, { + inputFields: { maxActorRuns: 1 }, + beforeStart: (mockApi) => mockApi.failNextRunCreate(400, JSON.stringify({ error: { message: 'bad actorId' } })), + }); + expect(result.pushedItem?.stdout).toBe('firstFailed=true secondSucceeded=true'); + }); + + it('rejects a non-finite/non-positive maxTotalChargeUsd before it touches the budget', async () => { + const result = await runScript(` + try { + await apify.actor.start({ actorId: 'apify/hello-world', maxTotalChargeUsd: NaN }); + console.log('LEAK: did not throw'); + } catch (e) { + console.log('error: ' + e.message); + } + `); + expect(result.pushedItem?.stdout).toMatch(/^error: Invalid maxTotalChargeUsd/); + }); + + it('actor.callAndGetItems does not double-count against maxActorRuns', async () => { + const result = await runScript(` + const { run, items } = await apify.actor.callAndGetItems({ actorId: 'apify/hello-world', limit: 5 }); + console.log('status=' + run.status + ' items=' + items.length); + `, { inputFields: { maxActorRuns: 1 } }); + expect(result.pushedItem?.stdout).toMatch(/^status=/); + expect(result.pushedItem?.stdout).not.toMatch(/Blocked actor run/); + }); +}); diff --git a/tests/sandbox-isolation.ts b/tests/sandbox-isolation.ts new file mode 100644 index 0000000..00dcb2c --- /dev/null +++ b/tests/sandbox-isolation.ts @@ -0,0 +1,81 @@ +// Actor probe for workerd isolation, egress guards, and binding access. +// `export {}` enables top-level await; build strips it before wrapping code. +export {}; + +const results: boolean[] = []; +function check(name: string, cond: boolean, detail = ''): void { + if (cond) { + console.log(`PASS ${name}: ${detail}`); + results.push(true); + } else { + console.error(`FAIL ${name}: ${detail}`); + results.push(false); + } +} + +// Node built-ins must be unavailable. +for (const mod of ['node:net', 'node:fs', 'node:dns', 'node:child_process']) { + let imported = false; + try { await import(mod); imported = true; } catch { /* expected */ } + check(`import ${mod} blocked`, !imported, imported ? 'IMPORTED (leak!)' : 'blocked'); +} + +// No process or require means no token access through Node globals. +check('process undefined', typeof process === 'undefined', `typeof process = ${typeof process}`); +check('require undefined', typeof require === 'undefined', `typeof require = ${typeof require}`); + +check('fetch available', typeof fetch === 'function', `typeof fetch = ${typeof fetch}`); + +// Classify guard rejection separately from network errors. +async function guardBlocks(url: string): Promise { + try { + await fetch(url); + return false; + } catch (e) { + return /Blocked fetch/.test((e as Error).message); + } +} + +for (const url of ['https://apify.com/', 'https://api.apify.com/v2/browser-info']) { + check(`allow ${url}`, !(await guardBlocks(url)), 'not blocked by guard'); +} + +// Block public-host look-alikes, URL tricks, and metadata IPs. +const blockedTargets = [ + 'https://example.com/', + 'https://evilapify.com/', + 'https://apify.com.evil.com/', + 'https://apify.com@evil.com/', + 'http://169.254.169.254/', +]; +for (const url of blockedTargets) { + check(`block ${url}`, await guardBlocks(url), 'blocked by guard'); +} + +// WebSocket and EventSource must not bypass the fetch guard. +function blocksConstruct(name: string, url: string): boolean { + const Ctor = (globalThis as Record)[name]; + if (typeof Ctor !== 'function') return true; + try { + new (Ctor as new (u: string) => unknown)(url); + return false; + } catch (e) { + return /Blocked/.test((e as Error).message); + } +} +check('WebSocket blocked', blocksConstruct('WebSocket', 'wss://echo.websocket.org'), 'no wss egress'); +check('EventSource blocked', blocksConstruct('EventSource', 'https://example.com/sse'), 'no SSE egress'); + +let bindingWorks = false; +try { + const found = await apify.store({ search: 'hello world', limit: 1 }); + bindingWorks = Array.isArray(found.items); +} catch (e) { + console.error(`apify.store threw: ${(e as Error).message}`); +} +check('apify binding works', bindingWorks, bindingWorks ? 'store ok' : 'binding broken'); + +const passed = results.filter(Boolean).length; +console.log(`\n=== SUMMARY: ${passed}/${results.length} passed ===`); +if (passed === results.length) console.log('ALL_TESTS_PASSED'); +else console.error(`SOME_TESTS_FAILED (${results.length - passed} failed)`); diff --git a/tests/unit/guard.test.ts b/tests/unit/guard.test.ts new file mode 100644 index 0000000..d94c5a2 --- /dev/null +++ b/tests/unit/guard.test.ts @@ -0,0 +1,315 @@ +// Offline unit tests for URL validation, redirects, and builtin captures. +// Stub fetch before importing guard.ts because import installs the guard. +import { describe, expect, it, vi, beforeAll } from 'vitest'; + +let guard: typeof import('../../worker/guard.js'); +let mockFetch: ReturnType; + +beforeAll(async () => { + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + guard = await import('../../worker/guard.js'); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +function redirectResponse(location: string, status: number): Response { + return new Response(null, { status, headers: { location } }); +} + +describe('isAllowedHost', () => { + it.each([ + ['apify.com', true], + ['api.apify.com', true], + ['deeply.nested.apify.com', true], + ['APIFY.COM', true], + ['apify.com.', true], + ['evilapify.com', false], + ['apify.com.evil.com', false], + ['notapify.com', false], + ['example.com', false], + ])('%s -> %s', (hostname, expected) => { + expect(guard.isAllowedHost(hostname)).toBe(expected); + }); + + // Regression test for String.prototype poisoning. + it('still rejects a disallowed host after String.prototype.endsWith is poisoned', () => { + const original = String.prototype.endsWith; + // eslint-disable-next-line no-extend-native -- deliberately simulating the attack this test guards against + String.prototype.endsWith = () => true; + try { + expect(guard.isAllowedHost('evil.com')).toBe(false); + } finally { + String.prototype.endsWith = original; + } + }); +}); + +describe('validateUrl', () => { + it('accepts an allowed https URL', () => { + expect(guard.validateUrl('https://api.apify.com/v2/foo').hostname).toBe('api.apify.com'); + }); + + it('accepts an allowed http URL', () => { + expect(guard.validateUrl('http://apify.com/').hostname).toBe('apify.com'); + }); + + it('rejects a disallowed host', () => { + expect(() => guard.validateUrl('https://example.com/')).toThrow(/only apify\.com and its subdomains/); + }); + + it('rejects the userinfo trick (real host is evil.com)', () => { + expect(() => guard.validateUrl('https://apify.com@evil.com/')).toThrow(/evil\.com/); + }); + + it('rejects the path trick (real host is evil.com)', () => { + expect(() => guard.validateUrl('https://evil.com/apify.com')).toThrow(/evil\.com/); + }); + + it('rejects a non-http(s) protocol', () => { + expect(() => guard.validateUrl('ftp://apify.com/')).toThrow(/protocol/); + }); + + it('rejects an unparseable URL', () => { + expect(() => guard.validateUrl('not a url')).toThrow(/only absolute http\(s\) URLs/); + }); + + it('resolves a Request object by its .url', () => { + expect(guard.validateUrl(new Request('https://apify.com/x')).hostname).toBe('apify.com'); + }); + + // Regression test for URL constructor poisoning. + it('still rejects a disallowed host after globalThis.URL is replaced with a lying constructor', () => { + const OriginalURL = globalThis.URL; + class LyingURL extends OriginalURL { + get hostname() { return 'apify.com'; } + } + globalThis.URL = LyingURL; + try { + expect(() => guard.validateUrl('http://example.com/')).toThrow(/only apify\.com/); + } finally { + globalThis.URL = OriginalURL; + } + }); +}); + +describe('nextRedirectInit', () => { + it('303 downgrades GET regardless of original method', () => { + expect(guard.nextRedirectInit({ method: 'POST', body: 'x' }, 303)).toEqual({ method: 'GET', body: undefined }); + }); + + it('301 downgrades POST to GET', () => { + expect(guard.nextRedirectInit({ method: 'POST', body: 'x' }, 301)).toEqual({ method: 'GET', body: undefined }); + }); + + it('302 downgrades POST to GET', () => { + expect(guard.nextRedirectInit({ method: 'POST', body: 'x' }, 302)).toEqual({ method: 'GET', body: undefined }); + }); + + it('301 preserves a GET request unchanged', () => { + const init = { method: 'GET' }; + expect(guard.nextRedirectInit(init, 301)).toBe(init); + }); + + it('307 preserves method and body', () => { + const init = { method: 'POST', body: 'x' }; + expect(guard.nextRedirectInit(init, 307)).toBe(init); + }); + + it('308 preserves method and body', () => { + const init = { method: 'POST', body: 'x' }; + expect(guard.nextRedirectInit(init, 308)).toBe(init); + }); + + it('defaults to GET when no method given', () => { + expect(guard.nextRedirectInit(undefined, 303)).toEqual({ method: 'GET', body: undefined }); + }); +}); + +describe('guardedFetch', () => { + it('rejects a disallowed URL before making any request', async () => { + mockFetch.mockClear(); + await expect(guard.guardedFetch('https://example.com/', undefined)).rejects.toThrow(/only apify\.com/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('performs the request when the URL is allowed', async () => { + mockFetch.mockClear(); + mockFetch.mockResolvedValueOnce(jsonResponse({ ok: true })); + const response = await guard.guardedFetch('https://api.apify.com/v2/browser-info', undefined); + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0]; + expect(init.redirect).toBe('manual'); + }); + + it('follows a redirect to another allowed host', async () => { + mockFetch.mockClear(); + mockFetch + .mockResolvedValueOnce(redirectResponse('https://sub.apify.com/next', 302)) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + const response = await guard.guardedFetch('https://apify.com/start', undefined); + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1][0]).toBe('https://sub.apify.com/next'); + }); + + it('re-validates each redirect hop and blocks a hop to a disallowed host', async () => { + mockFetch.mockClear(); + mockFetch.mockResolvedValueOnce(redirectResponse('https://evil.com/steal', 302)); + await expect(guard.guardedFetch('https://apify.com/start', undefined)).rejects.toThrow(/only apify\.com/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('resolves a relative redirect Location against the current URL', async () => { + mockFetch.mockClear(); + mockFetch + .mockResolvedValueOnce(redirectResponse('/v2/next', 302)) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + await guard.guardedFetch('https://api.apify.com/v2/start', undefined); + expect(mockFetch.mock.calls[1][0]).toBe('https://api.apify.com/v2/next'); + }); + + it('downgrades a POST to GET on a 302, dropping the body', async () => { + mockFetch.mockClear(); + mockFetch + .mockResolvedValueOnce(redirectResponse('https://apify.com/next', 302)) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + await guard.guardedFetch('https://apify.com/start', { method: 'POST', body: 'payload' }); + const [, secondInit] = mockFetch.mock.calls[1]; + expect(secondInit.method).toBe('GET'); + expect(secondInit.body).toBeUndefined(); + }); + + it('gives up after exactly MAX_REDIRECT_HOPS redirects to allowed hosts', async () => { + // Pins the redirect limit through observable request count. + mockFetch.mockClear(); + for (let i = 0; i < 10; i++) mockFetch.mockResolvedValueOnce(redirectResponse('https://apify.com/loop', 302)); + await expect(guard.guardedFetch('https://apify.com/start', undefined)).rejects.toThrow(/exceeded 5 redirects/); + expect(mockFetch).toHaveBeenCalledTimes(6); + }); + + it('returns a redirect response unchanged when it carries no Location header', async () => { + mockFetch.mockClear(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 302 })); + const response = await guard.guardedFetch('https://apify.com/start', undefined); + expect(response.status).toBe(302); + }); +}); + +describe('module exports', () => { + it('never exports a raw/unrestricted fetch capability', () => { + // Guard against exporting unrestricted fetch capabilities. + const knownSafeExports = new Set([ + 'isAllowedHost', 'validateUrl', 'nextRedirectInit', 'guardedFetch', + 'realObjectFreeze', 'setHas', 'numberIsFinite', 'mathMin', 'realNumber', + 'encodeUriComponent', 'jsonStringify', 'urlHostname', 'urlProtocol', + 'responseOk', 'responseStatus', 'RealURL', + ]); + for (const key of Object.keys(guard)) { + expect(knownSafeExports.has(key)).toBe(true); + } + }); +}); + +// Each captured builtin gets a poisoning regression test. +describe('captured builtins resist prototype/static-method poisoning', () => { + it('realObjectFreeze still freezes after the global Object.freeze is replaced with a no-op', () => { + const original = Object.freeze; + Object.freeze = ((o: T) => o) as typeof Object.freeze; + try { + const obj = guard.realObjectFreeze({ x: 1 }); + expect(Object.isFrozen(obj)).toBe(true); + } finally { + Object.freeze = original; + } + }); + + it('setHas still reports true membership after Set.prototype.has is poisoned to always return false', () => { + const original = Set.prototype.has; + Set.prototype.has = () => false; + try { + expect(guard.setHas(new Set(['a']), 'a')).toBe(true); + } finally { + Set.prototype.has = original; + } + }); + + it('numberIsFinite still rejects Infinity after the global Number.isFinite is poisoned to always return true', () => { + const original = Number.isFinite; + Number.isFinite = () => true; + try { + expect(guard.numberIsFinite(Infinity)).toBe(false); + } finally { + Number.isFinite = original; + } + }); + + it('mathMin still returns the real minimum after the global Math.min is poisoned', () => { + const original = Math.min; + Math.min = (a) => a; + try { + expect(guard.mathMin(5, 2)).toBe(2); + } finally { + Math.min = original; + } + }); + + it('realNumber still coerces correctly after the global Number is poisoned', () => { + const original = globalThis.Number; + // @ts-expect-error -- simulate a poisoned global. + globalThis.Number = () => 999; + try { + expect(guard.realNumber('42')).toBe(42); + } finally { + globalThis.Number = original; + } + }); + + it('encodeUriComponent still escapes after the global encodeURIComponent is poisoned to a no-op', () => { + const original = globalThis.encodeURIComponent; + globalThis.encodeURIComponent = (x) => String(x); + try { + expect(guard.encodeUriComponent('../secret')).toBe('..%2Fsecret'); + } finally { + globalThis.encodeURIComponent = original; + } + }); + + it('jsonStringify still serializes real data after the global JSON.stringify is poisoned', () => { + const original = JSON.stringify; + JSON.stringify = () => '{"forged":true}'; + try { + expect(guard.jsonStringify({ real: 1 })).toBe('{"real":1}'); + } finally { + JSON.stringify = original; + } + }); + + it('responseOk still reads the real status after Response.prototype.ok is poisoned to always return true', () => { + const original = Object.getOwnPropertyDescriptor(Response.prototype, 'ok')!; + Object.defineProperty(Response.prototype, 'ok', { get: () => true, configurable: true }); + try { + const failedResponse = new Response(null, { status: 500 }); + expect(guard.responseOk(failedResponse)).toBe(false); + } finally { + Object.defineProperty(Response.prototype, 'ok', original); + } + }); + + it('urlHostname/urlProtocol still read the real values after URL.prototype accessors are poisoned', () => { + const originalHostname = Object.getOwnPropertyDescriptor(URL.prototype, 'hostname')!; + const originalProtocol = Object.getOwnPropertyDescriptor(URL.prototype, 'protocol')!; + Object.defineProperty(URL.prototype, 'hostname', { get: () => 'apify.com', configurable: true }); + Object.defineProperty(URL.prototype, 'protocol', { get: () => 'https:', configurable: true }); + try { + // Regression test for URL accessor poisoning. + expect(() => guard.validateUrl('http://example.com/')).toThrow(/only apify\.com/); + } finally { + Object.defineProperty(URL.prototype, 'hostname', originalHostname); + Object.defineProperty(URL.prototype, 'protocol', originalProtocol); + } + }); +}); diff --git a/tsconfig.integration.json b/tsconfig.integration.json new file mode 100644 index 0000000..d566980 --- /dev/null +++ b/tsconfig.integration.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["es2022"], + "types": ["node"] + }, + "include": ["tests/integration/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fb3c19a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "lib": ["es2022", "dom"], + "strict": true + }, + // Integration tests use Node APIs and compile separately. + "include": ["worker/*.ts", "tests/*.ts", "tests/unit/*.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..54b1f70 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +// Exclude compiled test artifacts from Vitest discovery. +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts'], + }, +}); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..687cc1a --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +// Integration tests need compiled worker files and have longer timeouts. +export default defineConfig({ + test: { + include: ['tests/integration/**/*.test.ts'], + testTimeout: 15_000, + }, +}); diff --git a/worker/config.capnp b/worker/config.capnp new file mode 100644 index 0000000..f8df6cd --- /dev/null +++ b/worker/config.capnp @@ -0,0 +1,46 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .codeRuntime), + # User fetches: guard.js allowlists hosts; workerd blocks private addresses. + (name = "internet", network = (allow = ["public"], tlsOptions = (trustBrowserCas = true))), + # Internal API binding; user code never receives this service. + (name = "internalApi", network = (allow = ["public", "private", "local"], tlsOptions = (trustBrowserCas = true))), + ], + # entrypoint.sh substitutes __PORT__ before startup. + sockets = [ + ( + name = "http", + address = "127.0.0.1:__PORT__", + http = (), + service = "main", + ), + ], +); + +# runner.js is the entrypoint; entrypoint.sh generates usercode.js. +const codeRuntime :Workerd.Worker = ( + modules = [ + (name = "runner.js", esModule = embed "runner.js"), + (name = "guard.js", esModule = embed "guard.js"), + (name = "usercode.js", esModule = embed "usercode.js"), + ], + bindings = [ + (name = "APIFY_TOKEN", fromEnvironment = "APIFY_TOKEN"), + (name = "DEFAULT_DATASET_ID", fromEnvironment = "ACTOR_DEFAULT_DATASET_ID"), + (name = "DEFAULT_DATASET_ID_LEGACY", fromEnvironment = "APIFY_DEFAULT_DATASET_ID"), + (name = "API_BASE_URL", fromEnvironment = "APIFY_API_BASE_URL"), + # Forward this run's verified origin to sub-runs. + (name = "PARENT_ORIGIN", fromEnvironment = "APIFY_META_ORIGIN"), + # Optional execution limits from Actor input. + (name = "MAX_ACTOR_RUNS", fromEnvironment = "CODE_RUNTIME_MAX_ACTOR_RUNS"), + (name = "MAX_TOTAL_CHARGE_USD", fromEnvironment = "CODE_RUNTIME_MAX_TOTAL_CHARGE_USD"), + (name = "DEFAULT_TIMEOUT_SECS", fromEnvironment = "CODE_RUNTIME_DEFAULT_TIMEOUT_SECS"), + # Internal API fetch, scoped to internalApi. + (name = "INTERNAL_API", service = "internalApi"), + ], + globalOutbound = "internet", + compatibilityDate = "2026-01-15", + # No nodejs_compat: blocks Node egress and process.env token access. +); diff --git a/worker/entrypoint.sh b/worker/entrypoint.sh new file mode 100755 index 0000000..c4714ee --- /dev/null +++ b/worker/entrypoint.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# Read input, start workerd, trigger one run, then exit. +set -eu + +PORT=8787 +READINESS_ATTEMPTS=100 # 100 * 0.1s = 10s budget for workerd to bind the socket + +API_BASE="${APIFY_API_BASE_URL:-https://api.apify.com}" +API_BASE="${API_BASE%/}" # APIFY_API_BASE_URL ships with a trailing slash +STORE_ID="${ACTOR_DEFAULT_KEY_VALUE_STORE_ID:-${APIFY_DEFAULT_KEY_VALUE_STORE_ID:-}}" +DATASET_ID="${ACTOR_DEFAULT_DATASET_ID:-${APIFY_DEFAULT_DATASET_ID:-}}" +INPUT_KEY="${APIFY_INPUT_KEY:-INPUT}" +WORKERD_STDERR=/tmp/workerd.err + +if [ -z "${APIFY_TOKEN:-}" ] || [ -z "$STORE_ID" ] || [ -z "$DATASET_ID" ]; then + echo "[code-runtime] missing APIFY_TOKEN or default key-value store / dataset ID" >&2 + exit 1 +fi + +# Insert input code directly into the generated module. +INPUT_URL="${API_BASE}/v2/key-value-stores/${STORE_ID}/records/${INPUT_KEY}" +input_status=$(curl -sS -o /tmp/input.json -w '%{http_code}' \ + -H "Authorization: Bearer ${APIFY_TOKEN}" "$INPUT_URL") +if [ "$input_status" != "200" ]; then + echo "[code-runtime] failed to read Actor input from ${INPUT_URL} (HTTP ${input_status})" >&2 + exit 1 +fi + +{ + printf 'export async function run(apify, console) {\n' + jq -r '.code // ""' < /tmp/input.json + printf '\n}\n' +} > /app/worker/usercode.js + +# Export optional execution limits; blank means unlimited. +export CODE_RUNTIME_MAX_ACTOR_RUNS="$(jq -r '.maxActorRuns // empty' < /tmp/input.json)" +export CODE_RUNTIME_MAX_TOTAL_CHARGE_USD="$(jq -r '.maxTotalChargeUsd // empty' < /tmp/input.json)" +export CODE_RUNTIME_DEFAULT_TIMEOUT_SECS="$(jq -r '.defaultTimeoutSecs // empty' < /tmp/input.json)" + +sed -i "s/__PORT__/${PORT}/" /app/worker/config.capnp + +/usr/local/bin/workerd serve --experimental /app/worker/config.capnp 2>"$WORKERD_STDERR" & +workerd_pid=$! +trap 'kill "$workerd_pid" 2>/dev/null || true' EXIT + +# Report user-code compile errors as diagnostic output. +push_compile_failure() { + crash_log=$(cat "$WORKERD_STDERR" 2>/dev/null || true) + echo "[code-runtime] usercode.js failed to compile: $crash_log" >&2 + item=$(jq -n --arg stderr "$crash_log" \ + '{stdout: "", stderr: $stderr, exitCode: 1, statusMessage: ("Failed to compile: " + $stderr)}') + push_status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ + "${API_BASE}/v2/datasets/${DATASET_ID}/items" \ + -H "Authorization: Bearer ${APIFY_TOKEN}" \ + -H 'content-type: application/json; charset=utf-8' \ + --data-binary "$item") + case "$push_status" in + 2??) exit 0 ;; + *) + echo "[code-runtime] failed to push compile-failure diagnostic (HTTP $push_status)" >&2 + exit 1 + ;; + esac +} + +attempt=0 +until curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; do + if ! kill -0 "$workerd_pid" 2>/dev/null; then + if grep -q 'usercode\.js' "$WORKERD_STDERR" 2>/dev/null; then + push_compile_failure + fi + echo "[code-runtime] workerd exited before startup: $(cat "$WORKERD_STDERR" 2>/dev/null)" >&2 + exit 1 + fi + attempt=$((attempt + 1)) + if [ "$attempt" -ge "$READINESS_ATTEMPTS" ]; then + echo "[code-runtime] workerd did not become ready in time" >&2 + exit 1 + fi + sleep 0.1 +done + +# Trigger one run; non-2xx fails the Actor run. +curl -fsS -X POST "http://127.0.0.1:${PORT}/run" diff --git a/worker/guard.ts b/worker/guard.ts new file mode 100644 index 0000000..8482964 --- /dev/null +++ b/worker/guard.ts @@ -0,0 +1,122 @@ +// Guard user fetches to apify.com and its subdomains. +// This module loads before usercode.js, including module-scope code. +// Internal API access uses env.INTERNAL_API, never an exported capability. +const realFetch = globalThis.fetch.bind(globalThis); + +// Capture security-sensitive builtins before usercode.js can replace globals, +// prototype methods, static functions, or accessors. Add a poisoning test with each capture. +const RealURL = globalThis.URL; +export const realObjectFreeze: typeof Object.freeze = Object.freeze.bind(Object); +const stringToLowerCase = String.prototype.toLowerCase; +const stringToUpperCase = String.prototype.toUpperCase; +const stringEndsWith = String.prototype.endsWith; +const stringSlice = String.prototype.slice; +const setHasMethod = Set.prototype.has; +export const setHas = (set: ReadonlySet, value: T): boolean => setHasMethod.call(set, value); +export const numberIsFinite: (value: unknown) => boolean = Number.isFinite; +export const mathMin: (a: number, b: number) => number = Math.min; +export const realNumber: (value: unknown) => number = Number; +export const encodeUriComponent: (value: string) => string = globalThis.encodeURIComponent; +export const jsonStringify: (value: unknown) => string = JSON.stringify.bind(JSON); +const urlHostnameGetter = Object.getOwnPropertyDescriptor(RealURL.prototype, 'hostname')!.get!; +const urlProtocolGetter = Object.getOwnPropertyDescriptor(RealURL.prototype, 'protocol')!.get!; +export const urlHostname = (url: URL): string => urlHostnameGetter.call(url); +export const urlProtocol = (url: URL): string => urlProtocolGetter.call(url); +const responseOkGetter = Object.getOwnPropertyDescriptor(Response.prototype, 'ok')!.get!; +const responseStatusGetter = Object.getOwnPropertyDescriptor(Response.prototype, 'status')!.get!; +export const responseOk = (response: Response): boolean => responseOkGetter.call(response); +export const responseStatus = (response: Response): number => responseStatusGetter.call(response); + +// runner.ts also uses this captured constructor for token-bearing API URLs. +export { RealURL }; + +// The leading dot rejects look-alikes such as evilapify.com. +export function isAllowedHost(hostname: string): boolean { + const lowercased: string = stringToLowerCase.call(hostname); + // Normalize a trailing FQDN dot without using another mutable method. + const host: string = stringEndsWith.call(lowercased, '.') ? stringSlice.call(lowercased, 0, -1) : lowercased; + return host === 'apify.com' || stringEndsWith.call(host, '.apify.com'); +} + +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input; + if (input instanceof RealURL) return input.href; + if (input && typeof input.url === 'string') return input.url; + return String(input); +} + +// Parse and validate one URL; callers use the result for relative redirects. +export function validateUrl(input: RequestInfo | URL): URL { + let url: URL; + try { + // RealURL defeats userinfo and path tricks, and cannot be hijacked by user code. + url = new RealURL(requestUrl(input)); + } catch { + throw new Error('Blocked fetch: only absolute http(s) URLs to apify.com are allowed'); + } + // Captured getters prevent URL.prototype poisoning. + const protocol = urlProtocol(url); + if (protocol !== 'https:' && protocol !== 'http:') { + throw new Error(`Blocked fetch: protocol "${protocol}" is not allowed`); + } + const hostname = urlHostname(url); + if (!isAllowedHost(hostname)) { + throw new Error(`Blocked fetch to "${hostname}": only apify.com and its subdomains are allowed`); + } + return url; +} + +// Follow redirects manually so every Location passes the allowlist. +// Preserve WHATWG method/body behavior for 301/302/303/307/308. +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const MAX_REDIRECT_HOPS = 5; + +export function nextRedirectInit(init: RequestInit | undefined, status: number): RequestInit | undefined { + const method: string = stringToUpperCase.call(init?.method ?? 'GET'); + const downgradeToGet = status === 303 || ((status === 301 || status === 302) && method === 'POST'); + if (!downgradeToGet) return init; + return { ...init, method: 'GET', body: undefined }; +} + +// Keep hop private so callers cannot bypass the redirect limit. +async function guardedFetchHop(input: RequestInfo | URL, init: RequestInit | undefined, hop: number): Promise { + if (hop > MAX_REDIRECT_HOPS) { + throw new Error(`Blocked fetch: exceeded ${MAX_REDIRECT_HOPS} redirects`); + } + const url = validateUrl(input); + const response = await realFetch(input, { ...init, redirect: 'manual' }); + const status = responseStatus(response); + if (!setHas(REDIRECT_STATUSES, status)) return response; + const location = response.headers.get('location'); + if (!location) return response; + const nextUrl = new RealURL(location, url); + return guardedFetchHop(nextUrl.href, nextRedirectInit(init, status), hop + 1); +} + +export function guardedFetch(input: RequestInfo | URL, init: RequestInit | undefined): Promise { + return guardedFetchHop(input, init, 0); +} + +// Lock fetch so user code cannot restore an unrestricted reference. +Object.defineProperty(globalThis, 'fetch', { + value: (input: RequestInfo | URL, init?: RequestInit) => guardedFetch(input, init), + writable: false, + configurable: false, + enumerable: true, +}); + +// Remove direct WebSocket and EventSource egress around the fetch guard. +function blockGlobal(name: string): void { + const blocked = function () { + throw new Error(`Blocked ${name}: only fetch() to apify.com and its subdomains is allowed`); + }; + Object.defineProperty(globalThis, name, { + value: blocked, + writable: false, + configurable: false, + enumerable: false, + }); +} +for (const name of ['WebSocket', 'EventSource']) { + if (name in globalThis) blockGlobal(name); +} diff --git a/worker/runner.ts b/worker/runner.ts new file mode 100644 index 0000000..1128601 --- /dev/null +++ b/worker/runner.ts @@ -0,0 +1,576 @@ +// Run usercode.js in workerd, expose the Apify binding, and push output. +// guard.js must load first to install egress guards before user code runs. +// Internal API access uses env.INTERNAL_API; user code never receives env. +// Security-sensitive builtins come from guard.js captures. +import { + realObjectFreeze, setHas, numberIsFinite, mathMin, realNumber, + encodeUriComponent, jsonStringify, responseOk, RealURL, +} from './guard.js'; +import { run } from './usercode.js'; + +type Fetcher = { fetch(input: RequestInfo | URL, init?: RequestInit): Promise }; + +const DEFAULT_GET_SCHEMA_SAMPLE = 5; + +// Apify caps one wait request at 60 seconds. +const DEFAULT_WAIT_FOR_FINISH_SECS = 60; + +// Only fields consumed by this runtime are typed. + +interface ApifyRecord { + [key: string]: unknown; +} + +interface RunRecord extends ApifyRecord { + id: string; + status: string; + defaultDatasetId: string; +} + +type SearchParamValue = string | number | boolean | undefined | null; +type SearchParams = Record; + +interface ApiCallOptions { + searchParams?: SearchParams; + body?: unknown; + contentType?: string; +} + +interface StoreSearchOptions { + search: string; + limit?: number; + offset?: number; + category?: string; +} + +interface ActorIdOptions { + actorId: string; +} + +interface StartOptions { + actorId: string; + input?: unknown; + memoryMbytes?: number; + timeoutSecs?: number; + waitForFinishSecs?: number; + maxTotalChargeUsd?: number; + maxItems?: number; +} + +interface RunAndGetItemsOptions extends StartOptions { + fields?: string[]; + limit?: number; +} + +interface RunIdOptions { + runId: string; +} + +interface WaitOptions extends RunIdOptions { + waitForFinishSecs?: number; +} + +interface GetLogOptions extends RunIdOptions { + limit?: number; +} + +interface DatasetListOptions { + datasetId: string; + fields?: string[]; + omit?: string[]; + limit?: number; + offset?: number; + clean?: boolean; + desc?: boolean; +} + +// One page returned by a paginated endpoint. +interface ItemsPage { + items: T[]; + count: number; + offset: number; + limit: number; +} + +interface DatasetItemsPage extends ItemsPage { + desc: boolean; +} + +// Await for one page; use for-await to consume all pages. +type PaginatedItems> = Promise & AsyncIterable; + +interface DatasetSchemaOptions { + datasetId: string; + sample?: number; +} + +interface DatasetSchema { + itemCount: unknown; + sampleSize: number; + fields: { name: string; types: string[]; nullable: boolean }[]; +} + +interface CreateOptions { + name?: string; +} + +interface PushItemsOptions { + datasetId: string; + items: unknown[]; +} + +interface KeyValueStoreGetOptions { + storeId: string; + key: string; +} + +interface KeyValueStoreSetOptions extends KeyValueStoreGetOptions { + value: unknown; + contentType?: string; +} + +interface KeyValueStoreListOptions { + storeId: string; + limit?: number; + exclusiveStartKey?: string; +} + +interface ConsoleLike { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; +} + +interface Env { + APIFY_TOKEN?: string; + DEFAULT_DATASET_ID?: string; + DEFAULT_DATASET_ID_LEGACY?: string; + API_BASE_URL?: string; + // Platform-verified origin; user input cannot spoof it. + PARENT_ORIGIN?: string; + // Optional limits for runs started by user code. + MAX_ACTOR_RUNS?: string; + MAX_TOTAL_CHARGE_USD?: string; + DEFAULT_TIMEOUT_SECS?: string; + // Separate network binding for internal API calls. + INTERNAL_API: Fetcher; +} + +interface OutputItem { + stdout: string; + stderr: string; + exitCode: number; + statusMessage: string; +} + +function stringify(x: unknown): string { + if (typeof x === 'string') return x; + try { return JSON.stringify(x); } catch { return String(x); } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function errorDetail(err: unknown): string { + return err instanceof Error && err.stack ? err.stack : errorMessage(err); +} + +// Return one page as a Promise and all pages through async iteration. +function makePaginatedList>( + fetchPage: (offset: number, limit: number | undefined) => Promise, + offset: number, + limit: number | undefined, +): PaginatedItems { + const firstPagePromise = fetchPage(offset, limit); + + async function* iterateAll(): AsyncGenerator { + let page = await firstPagePromise; + yield* page.items; + let nextOffset = page.offset + page.items.length; + while (page.items.length > 0 && page.items.length >= page.limit) { + page = await fetchPage(nextOffset, page.limit); + yield* page.items; + nextOffset += page.items.length; + } + } + + return Object.defineProperty(firstPagePromise, Symbol.asyncIterator, { + value: iterateAll, + }) as PaginatedItems; +} + +// Matches apify-core's existing MCP request-origin value. +const MCP_ORIGIN = 'MCP'; +const REQUEST_ORIGIN_HEADER = 'X-Apify-Request-Origin'; + +// Limits apply across runs started by one script. +interface Limits { + maxActorRuns: number | undefined; + maxTotalChargeUsd: number | undefined; + defaultTimeoutSecs: number | undefined; +} + +function makeApifyBinding({ token, apiV2, parentOrigin, internalFetch, limits }: { + token: string; + apiV2: string; + parentOrigin: string | undefined; + internalFetch: Fetcher['fetch']; + limits: Limits; +}) { + // Forward MCP origin only when platform metadata verified it. + const baseHeaders: Record = { + Authorization: `Bearer ${token}`, + 'User-Agent': 'apify-code-runtime', + ...(parentOrigin === MCP_ORIGIN ? { [REQUEST_ORIGIN_HEADER]: MCP_ORIGIN } : {}), + }; + + const buildUrl = (path: string, searchParams?: SearchParams): URL => { + const url = new RealURL(`${apiV2}${path}`); + if (searchParams) { + for (const [key, value] of Object.entries(searchParams)) { + if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); + } + } + return url; + }; + + // Shared HTTP wrapper; errors include response bodies. + const apiCall = async (method: string, path: string, options: ApiCallOptions = {}): Promise => { + const { searchParams, body, contentType } = options; + const headers: Record = { ...baseHeaders }; + let requestBody: BodyInit | undefined; + if (body !== undefined) { + if (typeof body === 'string' || body instanceof Uint8Array || body instanceof ArrayBuffer) { + // Cast: this TS/DOM-lib pairing types Uint8Array generically over its buffer, + // which doesn't structurally match BodyInit here even though it's a valid + // fetch body at runtime (an ArrayBufferView). + requestBody = body as BodyInit; + headers['content-type'] = contentType ?? 'application/octet-stream'; + } else { + requestBody = jsonStringify(body); + headers['content-type'] = contentType ?? 'application/json'; + } + } + const response = await internalFetch(buildUrl(path, searchParams), { method, headers, body: requestBody }); + if (!responseOk(response)) throw new Error(`${method} ${path} failed: ${response.status} ${await response.text()}`); + return response; + }; + + // API envelopes are untyped because endpoint shapes vary. + const apiJson = async (method: string, path: string, options?: ApiCallOptions): Promise => + (await apiCall(method, path, options)).json(); + const apiData = async (method: string, path: string, options?: ApiCallOptions): Promise => + (await apiJson(method, path, options)).data; + + // Scope run.abort() and crash cleanup to runs started by this script. + const startedRunIds = new Set(); + // ABORTING needs no second abort; unknown statuses stay tracked conservatively. + const DONE_TRACKING_STATUSES = new Set(['SUCCEEDED', 'FAILED', 'ABORTED', 'ABORTING', 'TIMED-OUT']); + const nonTerminalRunIds = new Set(); + + // Track committed run ceilings, not realized spend. + let committedChargeUsd = 0; + // Reserve synchronously so concurrent starts cannot bypass maxActorRuns. + let reservedRunCount = 0; + + // Start one Actor run; actor.call() and actor.start() share this path. + const createRun = async ({ actorId, input, memoryMbytes, timeoutSecs, waitForFinishSecs, maxTotalChargeUsd, maxItems }: StartOptions): Promise => { + if (limits.maxActorRuns !== undefined && reservedRunCount >= limits.maxActorRuns) { + throw new Error(`Blocked actor run: this script already started/is starting ${reservedRunCount} Actor run(s), the configured limit is ${limits.maxActorRuns}`); + } + // Reject non-finite or non-positive caps before budget arithmetic. + if (maxTotalChargeUsd !== undefined && !(numberIsFinite(maxTotalChargeUsd) && maxTotalChargeUsd > 0)) { + throw new Error(`Invalid maxTotalChargeUsd: ${maxTotalChargeUsd} (must be a finite number greater than 0)`); + } + let effectiveMaxCharge = maxTotalChargeUsd; + if (limits.maxTotalChargeUsd !== undefined) { + const remaining = limits.maxTotalChargeUsd - committedChargeUsd; + if (remaining <= 0) { + throw new Error(`Blocked actor run: execution spending budget of $${limits.maxTotalChargeUsd} is exhausted`); + } + // Uncapped runs consume the remainder; larger caps are clamped. + effectiveMaxCharge = effectiveMaxCharge === undefined ? remaining : mathMin(effectiveMaxCharge, remaining); + } + // Reserve before the network request to close the concurrency race. + reservedRunCount += 1; + if (effectiveMaxCharge !== undefined) committedChargeUsd += effectiveMaxCharge; + try { + const runRecord: RunRecord = await apiData('POST', `/acts/${encodeUriComponent(actorId)}/runs`, { + searchParams: { + waitForFinish: waitForFinishSecs, + memory: memoryMbytes, + timeout: timeoutSecs ?? limits.defaultTimeoutSecs, + maxTotalChargeUsd: effectiveMaxCharge, + maxItems, + }, + body: input ?? {}, + }); + startedRunIds.add(runRecord.id); + if (!setHas(DONE_TRACKING_STATUSES, runRecord.status)) nonTerminalRunIds.add(runRecord.id); + return runRecord; + } catch (err) { + // Failed requests release their reservations. + reservedRunCount -= 1; + if (effectiveMaxCharge !== undefined) committedChargeUsd -= effectiveMaxCharge; + throw err; + } + }; + + const actor = { + get: ({ actorId }: ActorIdOptions): Promise => + apiData('GET', `/acts/${encodeUriComponent(actorId)}`), + + call: (opts: StartOptions): Promise => createRun({ waitForFinishSecs: DEFAULT_WAIT_FOR_FINISH_SECS, ...opts }), + + start: (opts: StartOptions): Promise => createRun(opts), + + // Start, wait up to 60 seconds, and read a dataset snapshot. + callAndGetItems: async ({ actorId, input, fields, limit, ...runOpts }: RunAndGetItemsOptions): Promise<{ run: RunRecord; items: ApifyRecord[] }> => { + const runRecord = await createRun({ actorId, input, waitForFinishSecs: DEFAULT_WAIT_FOR_FINISH_SECS, ...runOpts }); + const { items } = await dataset.listItems({ + datasetId: runRecord.defaultDatasetId, fields, limit, + }); + return { run: runRecord, items }; + }, + }; + + const run = { + get: ({ runId }: RunIdOptions): Promise => + apiData('GET', `/actor-runs/${encodeUriComponent(runId)}`), + + // Wait for termination or the API's 60-second cap. + waitForFinish: async ({ runId, waitForFinishSecs = DEFAULT_WAIT_FOR_FINISH_SECS }: WaitOptions): Promise => { + const runRecord: RunRecord = await apiData('GET', `/actor-runs/${encodeUriComponent(runId)}`, { + searchParams: { waitForFinish: waitForFinishSecs }, + }); + if (setHas(DONE_TRACKING_STATUSES, runRecord.status)) nonTerminalRunIds.delete(runId); + return runRecord; + }, + + // Only runs started by this script may be aborted. + abort: ({ runId }: RunIdOptions): Promise => { + if (!setHas(startedRunIds, runId)) { + throw new Error(`Blocked run.abort: "${runId}" was not started by this script`); + } + nonTerminalRunIds.delete(runId); + return apiData('POST', `/actor-runs/${encodeUriComponent(runId)}/abort`); + }, + + // Return the full log, optionally limited to its last N characters. + getLog: async ({ runId, limit }: GetLogOptions): Promise => { + const response = await apiCall('GET', `/logs/${encodeUriComponent(runId)}`); + const text = await response.text(); + return limit && text.length > limit ? text.slice(-limit) : text; + }, + }; + + const dataset = { + // Await for one page; for-await iterates all pages. + listItems: ({ datasetId, fields, omit, limit, offset = 0, clean, desc }: DatasetListOptions): PaginatedItems => { + const fetchPage = async (pageOffset: number, pageLimit?: number): Promise => { + const response = await apiCall('GET', `/datasets/${encodeUriComponent(datasetId)}/items`, { + searchParams: { + fields: fields?.join(','), + omit: omit?.join(','), + limit: pageLimit, + offset: pageOffset, + clean: clean ? '1' : undefined, + desc: desc ? '1' : undefined, + }, + }); + const items: ApifyRecord[] = await response.json(); + return { + items, + count: items.length, + offset: Number(response.headers.get('x-apify-pagination-offset') ?? pageOffset), + limit: Number(response.headers.get('x-apify-pagination-limit') ?? pageLimit ?? items.length), + desc: response.headers.get('x-apify-pagination-desc') === 'true', + }; + }; + return makePaginatedList(fetchPage, offset, limit); + }, + + // Infer fields from a sample because the API has no schema endpoint. + inferFields: async ({ datasetId, sample = DEFAULT_GET_SCHEMA_SAMPLE }: DatasetSchemaOptions): Promise => { + const meta = await apiData('GET', `/datasets/${encodeUriComponent(datasetId)}`); + const { items } = await dataset.listItems({ datasetId, limit: sample }); + const fields = new Map>(); + for (const item of items) { + for (const [name, value] of Object.entries(item ?? {})) { + const type = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value; + if (!fields.has(name)) fields.set(name, new Set()); + fields.get(name)!.add(type); + } + } + return { + itemCount: meta?.itemCount, + sampleSize: items.length, + fields: [...fields.entries()].map(([name, types]) => ({ + name, + types: [...types], + nullable: types.has('null'), + })), + }; + }, + + create: ({ name }: CreateOptions = {}): Promise => + apiData('POST', '/datasets', { searchParams: { name } }), + + pushItems: async ({ datasetId, items }: PushItemsOptions): Promise => { + await apiCall('POST', `/datasets/${encodeUriComponent(datasetId)}/items`, { body: items }); + }, + }; + + const keyValueStore = { + // Parse JSON/text values; return null for a missing key. + get: async ({ storeId, key }: KeyValueStoreGetOptions): Promise => { + const response = await internalFetch(buildUrl(`/key-value-stores/${encodeUriComponent(storeId)}/records/${encodeUriComponent(key)}`), { + headers: baseHeaders, + }); + if (response.status === 404) return null; + if (!responseOk(response)) throw new Error(`GET keyValueStore.get failed: ${response.status} ${await response.text()}`); + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) return response.json(); + if (contentType.startsWith('text/')) return response.text(); + return new Uint8Array(await response.arrayBuffer()); + }, + + // Objects, strings, and binary values use matching content types. + set: async ({ storeId, key, value, contentType }: KeyValueStoreSetOptions): Promise => { + let body: BodyInit; + let resolvedContentType = contentType; + if (value instanceof Uint8Array || value instanceof ArrayBuffer) { + // Cast: see the equivalent Uint8Array-vs-BodyInit comment in apiCall() above. + body = value as BodyInit; + resolvedContentType = resolvedContentType ?? 'application/octet-stream'; + } else if (typeof value === 'string') { + body = value; + resolvedContentType = resolvedContentType ?? 'text/plain; charset=utf-8'; + } else { + body = jsonStringify(value); + resolvedContentType = resolvedContentType ?? 'application/json; charset=utf-8'; + } + await apiCall('PUT', `/key-value-stores/${encodeUriComponent(storeId)}/records/${encodeUriComponent(key)}`, { + body, contentType: resolvedContentType, + }); + }, + + list: ({ storeId, limit, exclusiveStartKey }: KeyValueStoreListOptions): Promise => + apiData('GET', `/key-value-stores/${encodeUriComponent(storeId)}/keys`, { + searchParams: { limit, exclusiveStartKey }, + }), + + create: ({ name }: CreateOptions = {}): Promise => + apiData('POST', '/key-value-stores', { searchParams: { name } }), + }; + + // Search the top-level Store resource with the shared paginator. + const store = ({ search, limit, offset = 0, category }: StoreSearchOptions): PaginatedItems> => { + const fetchPage = async (pageOffset: number, pageLimit: number | undefined): Promise> => { + const page = await apiData('GET', '/store', { searchParams: { search, limit: pageLimit, offset: pageOffset, category } }); + const items: ApifyRecord[] = page.items; + return { items, count: items.length, offset: pageOffset, limit: pageLimit ?? items.length }; + }; + return makePaginatedList(fetchPage, offset, limit); + }; + + // Best-effort cleanup; one failed abort must not stop the others. + const abortTrackedRuns = async (): Promise<{ runId: string; error: string }[]> => { + const runIds = [...nonTerminalRunIds]; + const results = await Promise.allSettled(runIds.map((runId) => run.abort({ runId }))); + return results.flatMap((result, i) => result.status === 'rejected' ? [{ runId: runIds[i], error: errorMessage(result.reason) }] : []); + }; + + // Freeze the binding so user code cannot replace its methods. + const binding = realObjectFreeze({ + actor: realObjectFreeze(actor), + store, + run: realObjectFreeze(run), + dataset: realObjectFreeze(dataset), + keyValueStore: realObjectFreeze(keyValueStore), + }); + return { binding, abortTrackedRuns }; +} + +// Type-only binding surface used by tests. +export type ApifyBinding = ReturnType['binding']; + +async function pushOutput({ apiV2, token, internalFetch, env, item }: { + apiV2: string; + token: string; + internalFetch: Fetcher['fetch']; + env: Env; + item: OutputItem; +}): Promise { + const datasetId = env.DEFAULT_DATASET_ID || env.DEFAULT_DATASET_ID_LEGACY; + if (!datasetId) throw new Error('Default dataset ID missing from Actor run environment.'); + const response = await internalFetch(`${apiV2}/datasets/${encodeUriComponent(datasetId)}/items`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json; charset=utf-8' }, + body: jsonStringify(item), + }); + if (!responseOk(response)) throw new Error(`Failed to push dataset item: ${response.status} ${await response.text()}`); +} + +// Blank or invalid values mean no configured limit. +function parsePositiveNumberEnv(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = realNumber(value); + return numberIsFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +// Freeze fetch so escaped module code cannot replace it. +export default realObjectFreeze({ + async fetch(request: Request, env: Env): Promise { + const url = new RealURL(request.url); + if (url.pathname === '/health') return new Response('ok'); + if (url.pathname !== '/run') return new Response('Not found', { status: 404 }); + + const token = env.APIFY_TOKEN; + if (!token) throw new Error('APIFY_TOKEN missing from Actor run environment.'); + const apiV2 = `${(env.API_BASE_URL || 'https://api.apify.com').replace(/\/+$/, '')}/v2`; + const internalFetch: Fetcher['fetch'] = (input, init) => env.INTERNAL_API.fetch(input, init); + + const limits: Limits = { + maxActorRuns: parsePositiveNumberEnv(env.MAX_ACTOR_RUNS), + maxTotalChargeUsd: parsePositiveNumberEnv(env.MAX_TOTAL_CHARGE_USD), + defaultTimeoutSecs: parsePositiveNumberEnv(env.DEFAULT_TIMEOUT_SECS), + }; + + const stdout: string[] = []; + const stderr: string[] = []; + // Freeze the captured console methods. + const captureConsole: ConsoleLike = realObjectFreeze({ + log: (...args: unknown[]) => stdout.push(args.map(stringify).join(' ')), + error: (...args: unknown[]) => stderr.push(args.map(stringify).join(' ')), + warn: (...args: unknown[]) => stderr.push(args.map(stringify).join(' ')), + info: (...args: unknown[]) => stdout.push(args.map(stringify).join(' ')), + }); + + const { binding, abortTrackedRuns } = makeApifyBinding({ token, apiV2, parentOrigin: env.PARENT_ORIGIN, internalFetch, limits }); + + // User errors become diagnostics; infrastructure errors fail the run. + let exitCode = 0; + let statusMessage = 'Script completed'; + try { + await run(binding, captureConsole); + } catch (err) { + stderr.push(errorDetail(err)); + exitCode = 1; + statusMessage = `Script threw: ${errorMessage(err)}`; + // Cleanup failures are diagnostics, not a second script failure. + const abortFailures = await abortTrackedRuns(); + for (const { runId, error } of abortFailures) { + stderr.push(`Cleanup: failed to abort run ${runId}: ${error}`); + } + } + + await pushOutput({ + apiV2, token, internalFetch, env, + item: { stdout: stdout.join('\n'), stderr: stderr.join('\n'), exitCode, statusMessage }, + }); + return Response.json({ ok: true }); + }, +}); diff --git a/worker/usercode.d.ts b/worker/usercode.d.ts new file mode 100644 index 0000000..64f2efa --- /dev/null +++ b/worker/usercode.d.ts @@ -0,0 +1,2 @@ +// Generated usercode.js wraps input code in run(apify, console). +export function run(apify: unknown, consoleLike: unknown): Promise;