From 693377fb2eab378d4d633e68dd87ff77045f6c35 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:03:15 +0000 Subject: [PATCH 01/11] feat: Keep recordings in a Cloudflare Worker with R2 and D1 Replace the shared Node proxy service with a remote store. Each CI run keeps its own local proxy, and only the recordings move to a Worker. This needs no raw TCP ingress and no shared HTTPS interception. The recorder now uses a RecordingStore: the file store (as before) or the remote store, which calls the Worker. The Worker keeps each recording in R2 and one D1 row with its last use and the hash of each request part. Miss diagnosis no longer reads every recording on the first miss. The Worker finds the closest recording with one indexed D1 query on the part hashes, and returns the parts that differ. Recordings expire by last use, not by upload: a replay refreshes the row at most once a day, and a daily cron deletes rows and objects that nobody used for RECORDING_TTL_DAYS. Tenants have hashed tokens and see only their own recordings. tests/worker.test.ts runs the real wrangler.jsonc with local R2 and D1 through createTestHarness: record and replay through a proxy, misses, tenant isolation, size limits, and expiry. Co-Authored-By: David Cramer --- .dev.vars.example | 3 + .gitignore | 3 + AGENTS.md | 16 +- README.md | 112 +++- knip.json | 2 +- package.json | 8 +- pnpm-lock.yaml | 877 +++++++++++++++++++++++++- pnpm-workspace.yaml | 2 + src/parts.ts | 56 ++ src/recorder.ts | 108 ++-- src/recordings.ts | 212 ++++--- src/remote-store.ts | 104 +++ src/report.ts | 2 +- src/request-key.ts | 50 +- src/store.ts | 75 +++ src/types.ts | 29 +- tests/worker.test.ts | 237 +++++++ worker/index.ts | 367 +++++++++++ worker/migrations/0001_recordings.sql | 28 + worker/tsconfig.json | 8 + wrangler.jsonc | 28 + 21 files changed, 2099 insertions(+), 228 deletions(-) create mode 100644 .dev.vars.example create mode 100644 src/parts.ts create mode 100644 src/remote-store.ts create mode 100644 src/store.ts create mode 100644 tests/worker.test.ts create mode 100644 worker/index.ts create mode 100644 worker/migrations/0001_recordings.sql create mode 100644 worker/tsconfig.json create mode 100644 wrangler.jsonc diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..900c8d6 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,3 @@ +# Copy to .dev.vars for `wrangler dev`. The value is the SHA-256 hex of each +# tenant token. This one is the hash of "local-token". +ROACH_TENANTS={"local":"c7ec7c548f5992a239dc52fea748b808b408fe083c4625e9aa6ffe717343f9c1"} diff --git a/.gitignore b/.gitignore index 55390c4..8a88d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ coverage # Auto-generated by dotagents — do not commit these files. agents.lock .agents/.gitignore +.wrangler +.dev.vars +dist diff --git a/AGENTS.md b/AGENTS.md index 89bee72..8a6ef75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,21 +13,25 @@ | Test case | `pnpm exec vitest run tests/roach.test.ts -t "…"` | | Lint file | `pnpm exec oxlint --deny-warnings src/server.ts` | | Format | `pnpm format` | -| Typecheck | `pnpm typecheck` | +| Typecheck | `pnpm typecheck` (proxy and Worker) | +| Worker tests | `pnpm exec vitest run tests/worker.test.ts` | | Unused code/deps | `pnpm knip` | | Everything (CI) | `pnpm check` | ## External References -| Need | File | -| -------------------------------------- | ---------------------- | -| Usage, config, recordings, control API | `README.md` | -| Public types | `src/types.ts` | -| Map of source files | "Files" in `README.md` | +| Need | File | +| -------------------------------------- | ----------------------- | +| Usage, config, recordings, control API | `README.md` | +| Worker setup, routes, and limits | "Worker" in `README.md` | +| Public types | `src/types.ts` | +| Map of source files | "Files" in `README.md` | ## Key Conventions - Add no runtime dependencies. Use Node built-ins and the `openssl` command only. +- `worker/` runs on Cloudflare Workers, with `worker/tsconfig.json`. The `src/` files that it imports (`parts.ts`, `store.ts`) must not use Node built-ins. +- Change the D1 schema only with a new file in `worker/migrations/`. - Node runs `src/` as TypeScript with type stripping. Use only erasable syntax, and import local files with the `.ts` extension. - Use functions and plain objects, not classes. - Start each source file with a comment that says what the file owns. Give each export a short JSDoc. diff --git a/README.md b/README.md index 3f31d18..9daf275 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,23 @@ # Roach -Roach is a recording HTTP proxy for tests. It records chosen requests to files in git -and replays them, so a second run of a test suite makes no live calls. The +Roach is a recording HTTP proxy for tests. It records chosen requests and +replays them, so a second run of a test suite makes no live calls. The proxy owns every read and write of the recordings. A test runner only gives it rules and tells it when each test starts and ends. +The recordings are in one of two stores: + +- **Files** (`directory`): JSON files that you commit to git. +- **A Roach Worker** (`store`): a Cloudflare Worker with R2 and D1. Many + repositories and CI runs can use it. It deletes recordings that nobody + used for 30 days. See [Worker](#worker). + +The proxy always runs in the process tree of the test run. Only the +recordings move to the Worker. + Roach started in [`getsentry/junior`](https://github.com/getsentry/junior/tree/main/packages/junior-evals/src/roach). -It uses only Node built-ins and the `openssl` command. +The proxy uses only Node built-ins and the `openssl` command. ## Use @@ -55,6 +65,9 @@ await proxy.close(); `src/types.ts` has the full types. - `directory`: where the recordings are. Each rule has a subdirectory. +- `store`: `{ url, token }` of a Roach Worker, in place of `directory`. + Set one of the two. The proxy calls the Worker directly, and never writes + the token to a recording. - `mode`: `auto` replays recordings and records misses. `replay` replays recordings and fails a miss with HTTP 412, so nothing goes live. `record` sends every request live and records it again. `off` records and replays @@ -62,12 +75,14 @@ await proxy.close(); - `allow`: the only origins that the proxy sends requests to. It refuses all other origins with HTTP 403. The upstream host always comes from this list, not from the client. -- `rules`: the traffic to record. `match` has `method`, `url` (a prefix), +- `rules`: the traffic to record. `name` uses letters, digits, `_`, `.`, + and `-`. `match` has `method`, `url` (a prefix), and `headers`. `keyHeaders` adds request headers to the key. `values` names the values that change from run to run. - `usedFile`: when the proxy stops, it lists here the recordings that sessions used. A failed session uses all recordings that it recorded - before, because it stops early. `cli.ts prune` takes these files. + before, because it stops early. `cli.ts prune` takes these files. Only + for `directory`. - `missDirectory`: for each miss, the proxy writes the request as the key sees it, and its diagnosis, to `//.json`. These files contain request bodies, but no request headers except `keyHeaders`. @@ -85,7 +100,8 @@ stream. Each response has an `x-roach` header: `replayed`, `live`, ## Recordings -A recording is `//.json`. The key is the hash of the +The key of a recording is `/.json`. The file store keeps it at +`/`. The hash is the hash of the rule, the method, the URL, the key headers, and the body. A JSON body has sorted keys. A recording keeps the response, the session that recorded it, and a short hash of each part of the request. It does not keep the request @@ -148,6 +164,12 @@ logs the parts that differ and adds the miss to `stats().misses`. `describeRecordingMisses()` in `report.ts` gives the first miss of each test, which is the one to fix. +- The file store reads all recordings of a rule on the first miss of that + rule in a run, and then keeps them in memory. +- The Worker keeps the hash of each part in a D1 index. A miss is one + query that reads only the rows with an equal part. It does not read the + recordings in R2. + ## Command line ```sh @@ -170,6 +192,74 @@ All calls need `Authorization: Bearer `. `client.ts` calls them. Returns HTTP 409 when the session is not open. - `GET /__roach/stats`: the totals of the run. +## Worker + +`worker/` is a Cloudflare Worker that keeps recordings for many proxies. +`wrangler.jsonc` is its config. + +- **R2** (`RECORDINGS`) keeps each recording at `/`. +- **D1** (`DB`) keeps one row for each recording: its rule, its session, + the time of its last use, and the hash of each request part. +- **Tenants**: each tenant has a token. The secret `ROACH_TENANTS` maps + each tenant name to the SHA-256 hex of its token. A tenant sees only its + own recordings. Give each repository its own tenant. Tenants do not + share recordings, because a key does not include credentials: a shared + key would replay one tenant's private response to another. +- **Expiry**: a replay or a write sets the time of last use. A replay + writes to D1 at most once a day for each recording. Once a day, the cron + trigger deletes recordings that nobody used for `RECORDING_TTL_DAYS` + (30). It deletes up to 10,000 in each run. + +### Set up + +```sh +pnpm exec wrangler r2 bucket create roach-recordings +# Add a backstop for objects that lost their D1 row (see Limits). +pnpm exec wrangler r2 bucket lifecycle add roach-recordings backstop --expire-days 180 +pnpm exec wrangler d1 create roach # put the database_id in wrangler.jsonc +# Each tenant token is a random secret. Keep only its hash in the Worker. +token=$(openssl rand -hex 32); hash=$(printf %s "$token" | sha256sum | cut -d' ' -f1) +printf '{"junior":"%s"}' "$hash" | pnpm exec wrangler secret put ROACH_TENANTS +pnpm worker:deploy # apply D1 migrations, then deploy +``` + +Then give the proxy `store: { url: "https://roach..workers.dev", +token }`. Keep the token in a CI secret. + +### Routes + +All routes need `Authorization: Bearer `. `src/remote-store.ts` +calls them. + +- `GET /v1/recordings/`: the recording, or HTTP 404. +- `PUT /v1/recordings/` with the recording: write it. Returns + `{"changed": true}` when it was new or changed. A body over 10 MiB gets + HTTP 413. +- `POST /v1/closest` with `{"rule", "parts", "session"}`: returns + `{"closest": {"key", "differs"}}`, or `{"closest": null}`. + +A bad token gets HTTP 401. A bad key or body gets HTTP 400. + +### Limits + +- A write puts the R2 object first, then the D1 row. If D1 fails, the + object has no row, so the cron trigger cannot delete it. The R2 + lifecycle rule above deletes it later. +- A tenant can write as many recordings as it wants. There is no quota. +- To add or remove a tenant, change `ROACH_TENANTS`. + +### Run it locally + +```sh +cp .dev.vars.example .dev.vars # the tenant "local" with token "local-token" +pnpm exec wrangler d1 migrations apply DB --local +pnpm worker:dev +``` + +`tests/worker.test.ts` runs the Worker from `wrangler.jsonc` with local R2 +and D1 (`createTestHarness` of Wrangler). It tests record and replay +through a real proxy, misses, tenants, limits, and expiry. + ## Development ```sh @@ -181,14 +271,17 @@ pnpm check # format, lint, types, unused code, and tests, as CI runs them ## Files -All source files are in `src/`. Tests are in `tests/`. +The proxy is in `src/`. The Worker is in `worker/`. Tests are in `tests/`. - `types.ts`: the configuration and the results. - `server.ts`: sockets, HTTPS interception, the allow list, and the control API. - `recorder.ts`: modes, sessions, replay, and misses. -- `request-key.ts`: the key and the parts of a request. -- `recordings.ts`: the recording files, the miss index, and prune. +- `request-key.ts`: the key and the part hashes of a request. +- `store.ts`: the recording format and the store contract. +- `recordings.ts`: the file store and prune. +- `remote-store.ts`: the store that calls the Worker. +- `parts.ts`: compares the parts of requests for miss diagnosis. - `values.ts`: changing values and their placeholders. - `streams.ts`: merges the deltas of a recorded model stream. - `certificates.ts`: the certificate authority for HTTPS. @@ -196,3 +289,4 @@ All source files are in `src/`. Tests are in `tests/`. API. - `report.ts`: text reports of a run. - `cli.ts`: the command line. +- `worker/index.ts`: the Worker. `worker/migrations/` has the D1 schema. diff --git a/knip.json b/knip.json index b39cc6a..82c5670 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "project": ["src/**/*.ts", "tests/**/*.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts", "worker/**/*.ts"], "ignoreBinaries": ["openssl"] } diff --git a/package.json b/package.json index d015c4a..d3f4522 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,12 @@ "skills:install": "dotagents --project install", "test": "vitest run", "test:coverage": "vitest run --coverage", - "typecheck": "tsc -p ." + "typecheck": "tsc -p . && tsc -p worker", + "worker:dev": "wrangler dev", + "worker:deploy": "wrangler d1 migrations apply DB --remote && wrangler deploy" }, "devDependencies": { + "@cloudflare/workers-types": "^5.20261008.1", "@sentry/dotagents": "^3.3.0", "@types/node": "^24.19.1", "@vitest/coverage-v8": "^5.0.3", @@ -44,7 +47,8 @@ "simple-git-hooks": "^2.14.0", "typescript": "^7.0.2", "undici": "^8.11.2", - "vitest": "^5.0.3" + "vitest": "^5.0.3", + "wrangler": "^4.149.0" }, "simple-git-hooks": { "pre-commit": "pnpm exec lint-staged", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec1942c..d8d1aa7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@cloudflare/workers-types': + specifier: ^5.20261008.1 + version: 5.20261008.1 '@sentry/dotagents': specifier: ^3.3.0 version: 3.3.0 @@ -43,7 +46,10 @@ importers: version: 8.11.2 vitest: specifier: ^5.0.3 - version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + wrangler: + specifier: ^4.149.0 + version: 4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1) packages: @@ -76,15 +82,386 @@ packages: resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} engines: {node: '>= 20.12.0'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20261006.1': + resolution: {integrity: sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261006.1': + resolution: {integrity: sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261006.1': + resolution: {integrity: sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261006.1': + resolution: {integrity: sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261006.1': + resolution: {integrity: sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20261008.1': + resolution: {integrity: sha512-Ya6zuPTAQPka+Mn5rMI5f8Zl5bihhm+ytqhHiMzMgpLDGrD24A1k/Z/JPSBIqhIC0bx0AclgYyoZoRs6UWVlmw==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -95,6 +472,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@napi-rs/wasm-runtime@1.2.5': resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -607,6 +987,15 @@ packages: cpu: [x64] os: [win32] + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@rolldown/binding-android-arm-eabi@1.2.13': resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -715,6 +1104,13 @@ packages: engines: {node: '>=20'} hasBin: true + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -888,6 +1284,9 @@ packages: ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} @@ -896,13 +1295,25 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -954,6 +1365,10 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + knip@6.40.0: resolution: {integrity: sha512-5Lp/ANJu3AkW5f+J+2QG14z7xKZGi/ybb5MU1EWjeGdt6MpuIaLpLFiyBi0ZKryTiuTHiJqo/WKJRaWYimvavw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1044,6 +1459,10 @@ packages: magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + miniflare@5.20261006.1-alpha: + resolution: {integrity: sha512-6ylv8tamkrspDI2R/cAYfv+YWmfGNdXe76Pi9TauEUWsFVXn3LNF9Yk8oduzf1a+TYsSRYTqlOE7+nGGRsMHdw==} + engines: {node: '>=22.0.0'} + nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1093,6 +1512,12 @@ packages: package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1112,6 +1537,20 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + simple-git-hooks@2.14.0: resolution: {integrity: sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A==} hasBin: true @@ -1138,6 +1577,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + tinybench@6.2.1: resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==} engines: {node: '>=20.0.0'} @@ -1173,10 +1616,17 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + undici@8.11.2: resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + vite@8.3.4: resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1270,11 +1720,44 @@ packages: engines: {node: '>=20.11'} hasBin: true + workerd@1.20261006.1: + resolution: {integrity: sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.149.0: + resolution: {integrity: sha512-OzK7xmB5r5iLKb3cIT3783g13fe6T7xyeKu9LEdUyl+DKAM2KP/jkjnE0OUhyw+mB/XJM9ycjgF3MWm8PR9amg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261006.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.9.1: resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} @@ -1307,6 +1790,35 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261006.1 + + '@cloudflare/workerd-darwin-64@1.20261006.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261006.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261006.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261006.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261006.1': + optional: true + + '@cloudflare/workers-types@5.20261008.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -1318,11 +1830,200 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -1332,6 +2033,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 @@ -1593,6 +2299,18 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.87.0': optional: true + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true @@ -1653,6 +2371,10 @@ snapshots: smol-toml: 1.9.0 zod: 4.6.5 + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 @@ -1741,7 +2463,7 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -1749,14 +2471,14 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))': + '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.3 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) '@vitest/spy@5.0.3': {} @@ -1768,14 +2490,49 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + blake3-wasm@2.1.5: {} + chai@6.3.0: {} chalk@5.6.2: {} + cookie@1.1.1: {} + detect-libc@2.1.2: {} + error-stack-parser-es@1.0.5: {} + es-module-lexer@2.3.2: {} + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1818,6 +2575,8 @@ snapshots: jsonc-parser@3.3.1: {} + kleur@4.1.5: {} + knip@6.40.0: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -1901,6 +2660,19 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.2 + miniflare@5.20261006.1-alpha(@types/node@24.19.1): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@24.19.1) + undici: 7.29.1 + workerd: 1.20261006.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + nanoid@3.3.20: {} obug@2.2.1: {} @@ -2009,6 +2781,10 @@ snapshots: package-manager-detector@1.9.0: {} + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -2042,6 +2818,41 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.13 '@rolldown/binding-win32-x64-msvc': 1.2.13 + semver@7.8.5: {} + + sharp@0.35.5(@types/node@24.19.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 24.19.1 + simple-git-hooks@2.14.0: {} sisteransi@1.0.5: {} @@ -2056,6 +2867,8 @@ snapshots: strip-json-comments@5.0.3: {} + supports-color@10.2.2: {} + tinybench@6.2.1: {} tinyexec@1.3.1: {} @@ -2099,9 +2912,15 @@ snapshots: undici-types@7.24.6: {} + undici@7.29.1: {} + undici@8.11.2: {} - vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1): + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -2110,14 +2929,15 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.19.1 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)): + vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2128,7 +2948,7 @@ snapshots: tinybench: 6.2.1 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 24.19.1 @@ -2140,6 +2960,47 @@ snapshots: why-is-node-running@3.2.1: {} + workerd@1.20261006.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261006.1 + '@cloudflare/workerd-darwin-arm64': 1.20261006.1 + '@cloudflare/workerd-linux-64': 1.20261006.1 + '@cloudflare/workerd-linux-arm64': 1.20261006.1 + '@cloudflare/workerd-windows-64': 1.20261006.1 + + wrangler@4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.2 + miniflare: 5.20261006.1-alpha(@types/node@24.19.1) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261006.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20261008.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + yaml@2.9.1: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8dcb504..8517a11 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,6 @@ # has time to be pulled before it reaches us. minimumReleaseAge: 1440 allowBuilds: + esbuild: true simple-git-hooks: true + workerd: true diff --git a/src/parts.ts b/src/parts.ts new file mode 100644 index 0000000..c643817 --- /dev/null +++ b/src/parts.ts @@ -0,0 +1,56 @@ +/** + * The parts of a request, which miss diagnosis compares. + * + * The parts are the method, the URL, each key header, each top-level field + * of a JSON body, and each item of a top-level array, such as + * `messages[3]`. A recording keeps a short hash of each part + * (`request-key.ts`). When a request has no recording, the store finds the + * recording with the most equal parts and reports the parts that differ. + * + * This file uses no Node built-ins, because the Worker (`worker/`) uses it + * too. + */ + +/** A short hash of each part of a request, by part name. */ +export type RequestParts = Record; + +const partOrder = new Intl.Collator("en", { numeric: true }).compare; + +/** How many parts of `parts` are equal in `other`. */ +const countEqualParts = (parts: RequestParts, other: RequestParts) => + Object.keys(parts).filter((name) => other[name] === parts[name]).length; + +/** The parts that are in only one of `a` and `b`, or differ, in order. */ +export function differingParts(a: RequestParts, b: RequestParts): string[] { + const names = new Set([...Object.keys(a), ...Object.keys(b)]); + return [...names].filter((name) => a[name] !== b[name]).toSorted(partOrder); +} + +/** + * The candidate with the most equal parts, and the parts that differ from + * it, in a readable order. Returns `undefined` when there is no candidate. + */ +export function closestRequest( + parts: RequestParts, + candidates: Iterable, +): { candidate: T; differs: string[] } | undefined { + let best: T | undefined; + let bestEqual = -1; + for (const candidate of candidates) { + const equal = countEqualParts(parts, candidate.parts); + if (equal > bestEqual) { + bestEqual = equal; + best = candidate; + } + } + return ( + best && { candidate: best, differs: differingParts(parts, best.parts) } + ); +} + +/** Name some parts, such as `messages[3], tools`. */ +export function describeParts(parts: string[]): string { + if (parts.length === 0) return "no part"; + const shown = parts.slice(0, 6).join(", "); + return parts.length > 6 ? `${shown} and ${parts.length - 6} more` : shown; +} diff --git a/src/recorder.ts b/src/recorder.ts index fbcfc1e..815323a 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -4,7 +4,8 @@ * `server.ts` owns the sockets. It gives the recorder each request that a * rule matches, with a function that sends the request live. The recorder * decides whether to replay, send live, or fail the request, and it owns - * every read and write of the recordings. + * every read and write of the recordings. A store keeps them: the file + * store (`recordings.ts`) or a Roach Worker (`remote-store.ts`). * * A session groups the requests of one test. One session is open at a * time. The recorder keeps the new recordings of a session in memory until @@ -19,14 +20,17 @@ import { mkdir, writeFile } from "node:fs/promises"; import type { IncomingHttpHeaders } from "node:http"; import path from "node:path"; +import { describeParts } from "./parts.ts"; +import { createFileStore } from "./recordings.ts"; +import { createRemoteStore } from "./remote-store.ts"; +import { keyRequest, type KeyedRequest } from "./request-key.ts"; +import { createSecrets } from "./secrets.ts"; import { - createRecordingIndex, - readRecording, - writeRecordings, + recordingKey, + RULE_NAME, type Recording, -} from "./recordings.ts"; -import { describeParts, keyRequest, type KeyedRequest } from "./request-key.ts"; -import { createSecrets } from "./secrets.ts"; + type RecordingStore, +} from "./store.ts"; import { isEventStream, mapStreamEvents, @@ -60,9 +64,9 @@ export type ResponseSource = "live" | "missed" | "replayed"; interface Session { name: string; - /** New recordings, by file. */ + /** New recordings, by key. */ recorded: Map; - /** Files that the session replayed. They stay used if it fails. */ + /** Keys that the session replayed. They stay used if it fails. */ replayed: Set; /** Requests that `replay` mode failed. */ missed: number; @@ -144,20 +148,42 @@ function fromRecording( return { status: response.status, headers: response.headers, body }; } +/** The store of a config. Throws when the config does not name one. */ +function storeOf(config: RoachConfig): RecordingStore { + if ((config.directory === undefined) === (config.store === undefined)) { + throw new Error("Roach config must set directory or store, not both"); + } + for (const rule of config.rules) { + if (!RULE_NAME.test(rule.name)) { + throw new Error(`Roach rule name is not valid: ${rule.name}`); + } + } + if (config.store) { + if (config.usedFile) throw new Error("usedFile needs directory"); + return createRemoteStore(config.store); + } + return createFileStore(config.directory!); +} + /** Create the recorder of one proxy run. */ export function createRecorder(config: RoachConfig) { + const store = storeOf(config); const missDirectory = config.missDirectory ? path.resolve(config.missDirectory) : undefined; if ( missDirectory && + config.directory !== undefined && !path.relative(config.directory, missDirectory).startsWith("..") ) { // Miss files hold request bodies. Keep them out of the committed files. throw new Error("missDirectory must not be inside directory"); } let session: Session | undefined; - const secrets = createSecrets(config.secrets); + const secrets = createSecrets([ + ...(config.secrets ?? []), + ...(config.store ? [config.store.token] : []), + ]); /** Recordings that passed sessions, or requests outside one, used. */ const used = new Set(); const stats: RecordingStats = { @@ -172,55 +198,42 @@ export function createRecorder(config: RoachConfig) { discarded: 0, passthrough: {}, }; - const indexes = new Map( - config.rules.map((rule) => [ - rule.name, - createRecordingIndex(path.join(config.directory, rule.name)), - ]), - ); - const relative = (file: string) => path.relative(config.directory, file); - const write = async (recordings: Array<[string, Recording]>) => { - stats.written += await writeRecordings(recordings); - for (const [file, recording] of recordings) { - indexes.get(path.basename(path.dirname(file)))!.add(file, recording); - } + stats.written += await store.write(recordings); }; - const markReplayed = (owner: Session | undefined, file: string) => { - if (owner && owner.passed === undefined) owner.replayed.add(file); - else used.add(file); + const markReplayed = (owner: Session | undefined, key: string) => { + if (owner && owner.passed === undefined) owner.replayed.add(key); + else used.add(key); }; const record = async ( owner: Session | undefined, - file: string, + key: string, recording: Recording, ) => { if (owner && owner.passed === undefined) { - owner.recorded.set(file, recording); + owner.recorded.set(key, recording); } else if (owner?.passed === false) { stats.discarded += 1; } else { - used.add(file); - await write([[file, recording]]); + used.add(key); + await write([[key, recording]]); } }; const reportMiss = async ( rule: RecordingRule, - file: string, + key: string, keyed: KeyedRequest, owner: Session | undefined, ) => { - const closest = await indexes - .get(rule.name)! - .closest(keyed.parts, owner?.name); + const closest = await store.closest(rule.name, keyed.parts, owner?.name); const miss: RecordingMiss = { rule: rule.name, session: owner?.name, - file: relative(file), - closest: closest && relative(closest.file), + file: key, + closest: closest?.key, differs: closest?.differs ?? [], }; if (stats.misses.length < MAX_MISSES) stats.misses.push(miss); @@ -248,17 +261,17 @@ export function createRecorder(config: RoachConfig) { session = undefined; ended.passed = passed; // A failed test can still show that a recording is in use. - for (const file of ended.replayed) used.add(file); + for (const key of ended.replayed) used.add(key); if (passed) { - for (const file of ended.recorded.keys()) used.add(file); + for (const key of ended.recorded.keys()) used.add(key); await write([...ended.recorded]); } else { stats.discarded += ended.recorded.size; // A failed test stops early, so it does not replay all of its // recordings. Keep every recording that it made before, so that a // prune does not delete them. - for (const index of indexes.values()) { - for (const file of await index.filesOf(ended.name)) used.add(file); + for (const key of (await store.keysOf?.(ended.name)) ?? []) { + used.add(key); } } return ended.missed; @@ -308,19 +321,19 @@ export function createRecorder(config: RoachConfig) { } } } - const file = path.join(config.directory, rule.name, `${keyed.key}.json`); + const key = recordingKey(rule.name, keyed.key); if (config.mode !== "record") { - const recording = await readRecording(file); + const recording = await store.read(key); if (recording) { counts.replayed += 1; - markReplayed(owner, file); + markReplayed(owner, key); return { ...fromRecording(recording, keyed.values), source: "replayed", }; } - await reportMiss(rule, file, keyed, owner); + await reportMiss(rule, key, keyed, owner); if (config.mode === "replay") { counts.missed += 1; if (owner) owner.missed += 1; @@ -346,7 +359,7 @@ export function createRecorder(config: RoachConfig) { ), ) as Recording; if (!isTemporaryStatus(recording.response.status)) { - await record(owner, file, recording); + await record(owner, key, recording); } return { ...fromRecording(recording, keyed.values), source: "live" }; }, @@ -387,8 +400,11 @@ export function createRecorder(config: RoachConfig) { async close(): Promise { await finish(false); if (config.usedFile) { - const files = [...used].map(relative).toSorted(); - await writeFile(config.usedFile, files.map((f) => `${f}\n`).join("")); + const keys = [...used].toSorted(); + await writeFile( + config.usedFile, + keys.map((key) => `${key}\n`).join(""), + ); } }, }; diff --git a/src/recordings.ts b/src/recordings.ts index f6f7816..0531716 100644 --- a/src/recordings.ts +++ b/src/recordings.ts @@ -1,138 +1,144 @@ /** - * The recording files of Roach. + * The file store, and prune. * - * A recording is one JSON file, `//.json`. It keeps - * the response of one request, the session (test) that recorded it, and - * the parts of the request (`request-key.ts`). It does not keep the request - * body, so prompts and other inputs are not committed. + * The file store keeps each recording (`store.ts`) in `/`, + * so recordings can be committed. For miss diagnosis, it reads all + * recordings of a rule on the first miss of that rule in a run. That is + * one read of the local disk per rule. The remote store does the same + * search in a database query. */ -import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import { + mkdir, + readdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; import path from "node:path"; -import { closestRequest, type RequestParts } from "./request-key.ts"; - -/** One recorded response. */ -export interface Recording { - /** The session (test) that recorded the response. */ - session?: string | undefined; - request: { method: string; url: string; parts: RequestParts }; - response: { - status: number; - headers: Record; - /** - * A text body keeps each changing value of its request as a - * placeholder (`values.ts`). - */ - body: string; - /** `base64` for a body that is not text, such as an image. */ - bodyEncoding: "base64" | "utf8"; - }; -} +import { closestRequest, type RequestParts } from "./parts.ts"; +import { + formatRecording, + type Recording, + type RecordingStore, +} from "./store.ts"; const isMissing = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT"; -/** Read a recording. Returns `undefined` when there is none. */ -export async function readRecording( - file: string, -): Promise { - try { - return JSON.parse(await readFile(file, "utf8")) as Recording; - } catch (error) { +const readText = (file: string) => + readFile(file, "utf8").catch((error: unknown) => { if (isMissing(error)) return undefined; throw error; - } -} + }); -/** Write recordings. Returns how many were new or changed. */ -export async function writeRecordings( - recordings: Iterable<[string, Recording]>, -): Promise { - const changed = await Promise.all( - [...recordings].map(async ([file, recording]) => { - const content = `${JSON.stringify(recording, null, 2)}\n`; - const previous = await readFile(file, "utf8").catch((error) => { - if (isMissing(error)) return undefined; - throw error; - }); - if (previous === content) return false; - await mkdir(path.dirname(file), { recursive: true }); - await writeFile(file, content); - return true; - }), - ); - return changed.filter(Boolean).length; -} +/** The rule of a key, `/.json`. */ +const ruleOf = (key: string) => key.slice(0, key.indexOf("/")); interface IndexEntry { - file: string; + key: string; session?: string | undefined; parts: RequestParts; } -/** The recordings of one rule, for miss diagnosis. */ -export interface RecordingIndex { - /** Add or replace a recording that the proxy wrote. */ - add(file: string, recording: Recording): void; - /** - * The recording with the most equal parts, and the parts that differ. - * Recordings of the same session come first, because a test usually - * sends the same requests as the last time it ran. - */ - closest( - parts: RequestParts, - session: string | undefined, - ): Promise<{ file: string; differs: string[] } | undefined>; - /** The recordings that the session `session` recorded. */ - filesOf(session: string): Promise; -} - -const entryOf = (file: string, recording: Recording): IndexEntry => ({ - file, - session: recording.session, - parts: recording.request.parts, -}); - -/** Index the recordings of one rule directory. It reads them on first use. */ -export function createRecordingIndex(directory: string): RecordingIndex { - const entries = new Map(); - let loaded: Promise | undefined; - const load = async () => { - const names = await readdir(directory).catch((error: unknown) => { - if (isMissing(error)) return []; - throw error; - }); - for (const name of names.filter((entry) => entry.endsWith(".json"))) { - const file = path.join(directory, name); - const recording = await readRecording(file); - if (recording && !entries.has(file)) { - entries.set(file, entryOf(file, recording)); - } +/** + * The store of the recordings in `directory`. For miss diagnosis, it reads + * all recordings of a rule on its first miss of that rule. + */ +export function createFileStore(directory: string): RecordingStore { + const indexes = new Map>>(); + const indexOf = (rule: string) => { + let index = indexes.get(rule); + if (!index) { + index = loadIndex(directory, rule); + indexes.set(rule, index); } + return index; }; return { - add(file, recording) { - entries.set(file, entryOf(file, recording)); + async read(key) { + const text = await readText(path.join(directory, key)); + return text === undefined ? undefined : (JSON.parse(text) as Recording); }, - async closest(parts, session) { - await (loaded ??= load()); - const all = [...entries.values()]; + + async write(recordings) { + const changed = await Promise.all( + recordings.map(async ([key, recording]) => { + const file = path.join(directory, key); + const content = formatRecording(recording); + // Keep a loaded index current, so later misses compare with it. + if (indexes.has(ruleOf(key))) { + (await indexOf(ruleOf(key))).set(key, entryOf(key, recording)); + } + if ((await readText(file)) === content) return false; + await mkdir(path.dirname(file), { recursive: true }); + // Write a new file and rename it, so a reader in another run + // never sees half of a recording. + const partial = `${file}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(partial, content); + await rename(partial, file); + return true; + }), + ); + return changed.filter(Boolean).length; + }, + + async closest(rule, parts, session) { + const all = [...(await indexOf(rule)).values()]; const same = all.filter((entry) => entry.session === session); const found = closestRequest( parts, session !== undefined && same.length > 0 ? same : all, ); - return found && { file: found.candidate.file, differs: found.differs }; + return found && { key: found.candidate.key, differs: found.differs }; }, - async filesOf(session) { - await (loaded ??= load()); - return [...entries.values()] - .filter((entry) => entry.session === session) - .map((entry) => entry.file); + + async keysOf(session) { + const names = await readdir(directory).catch((error: unknown) => { + if (isMissing(error)) return []; + throw error; + }); + const keys: string[] = []; + for (const rule of names) { + for (const entry of (await indexOf(rule)).values()) { + if (entry.session === session) keys.push(entry.key); + } + } + return keys; }, }; } +const entryOf = (key: string, recording: Recording): IndexEntry => ({ + key, + session: recording.session, + parts: recording.request.parts, +}); + +/** Read every recording of one rule, by key. */ +async function loadIndex( + directory: string, + rule: string, +): Promise> { + const names = await readdir(path.join(directory, rule)).catch( + (error: unknown) => { + if (isMissing(error)) return []; + throw error; + }, + ); + const index = new Map(); + for (const name of names.filter((entry) => entry.endsWith(".json"))) { + const key = `${rule}/${name}`; + const text = await readText(path.join(directory, key)); + if (text !== undefined) { + index.set(key, entryOf(key, JSON.parse(text) as Recording)); + } + } + return index; +} + /** * Delete the recordings in `directory` that no used file lists. Each used * file comes from `usedFile` of one proxy run. Returns how many it deleted. diff --git a/src/remote-store.ts b/src/remote-store.ts new file mode 100644 index 0000000..1c05ea5 --- /dev/null +++ b/src/remote-store.ts @@ -0,0 +1,104 @@ +/** + * The remote store: the recordings are in a Roach Worker (`worker/`). + * + * The proxy calls the Worker directly, not through itself, with the token + * of its tenant. The Worker finds the closest recording of a miss in a + * database query, so a miss costs one call and not one read per recording. + * "Worker API" in `README.md` has the routes. + */ +import type { RequestParts } from "./parts.ts"; +import { + formatRecording, + type ClosestRecording, + type Recording, + type RecordingStore, +} from "./store.ts"; +import type { RemoteStoreConfig } from "./types.ts"; + +/** A call that takes longer than this fails. */ +const TIMEOUT_MS = 30_000; +/** Writes that run at the same time. */ +const WRITE_CONCURRENCY = 8; +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** The Worker URL. Plain HTTP is only for a local Worker. */ +function parseBase(value: string): URL { + const url = new URL(value.endsWith("/") ? value : `${value}/`); + if ( + url.protocol !== "https:" && + !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) + ) { + throw new Error(`Roach store URL must use https: ${value}`); + } + return url; +} + +/** The store of the recordings in the Worker of `config`. */ +export function createRemoteStore(config: RemoteStoreConfig): RecordingStore { + const base = parseBase(config.url); + + const call = async ( + method: "GET" | "POST" | "PUT", + route: string, + body?: string, + ): Promise => { + const response = await fetch(new URL(`v1/${route}`, base), { + method, + headers: { + authorization: `Bearer ${config.token}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!response.ok && !(method === "GET" && response.status === 404)) { + throw new Error( + `Roach store ${method} ${route} failed with HTTP ${response.status}: ${await response.text()}`, + ); + } + return response; + }; + + const writeOne = async ([key, recording]: [string, Recording]) => { + const response = await call( + "PUT", + `recordings/${key}`, + formatRecording(recording), + ); + return ((await response.json()) as { changed: boolean }).changed; + }; + + return { + async read(key) { + const response = await call("GET", `recordings/${key}`); + if (response.status === 404) { + await response.body?.cancel(); + return undefined; + } + return (await response.json()) as Recording; + }, + + async write(recordings) { + let changed = 0; + for (let at = 0; at < recordings.length; at += WRITE_CONCURRENCY) { + const results = await Promise.all( + recordings.slice(at, at + WRITE_CONCURRENCY).map(writeOne), + ); + changed += results.filter(Boolean).length; + } + return changed; + }, + + async closest(rule: string, parts: RequestParts, session) { + const response = await call( + "POST", + "closest", + JSON.stringify({ rule, parts, session: session ?? null }), + ); + const { closest } = (await response.json()) as { + closest: ClosestRecording | null; + }; + return closest ?? undefined; + }, + }; +} diff --git a/src/report.ts b/src/report.ts index 52b8a78..67e34e5 100644 --- a/src/report.ts +++ b/src/report.ts @@ -1,7 +1,7 @@ /** * Text reports of a Roach run, for logs and CI summaries. */ -import { describeParts } from "./request-key.ts"; +import { describeParts } from "./parts.ts"; import type { RecordingMiss, RecordingStats } from "./types.ts"; /** Describe the totals of a run in one line. */ diff --git a/src/request-key.ts b/src/request-key.ts index d113d32..4f14831 100644 --- a/src/request-key.ts +++ b/src/request-key.ts @@ -1,20 +1,17 @@ /** - * The key of a recorded request, and the parts that miss diagnosis - * compares. + * The key of a recorded request, and the hashes of its parts. * * The key is the hash of the rule, the method, the URL, the key headers, * and the body. A JSON body has sorted object keys, so key order does not * matter. Each changing value of the rule is `<>` in the key * (`values.ts`). * - * A recording also keeps a short hash of each part of its request: the - * method, the URL, each key header, each top-level field of a JSON body, - * and each item of a top-level array, such as `messages[3]`. When a request - * has no recording, the proxy finds the recording with the most equal parts - * and reports the parts that differ. + * A recording also keeps a short hash of each part of its request + * (`parts.ts`). */ import { createHash } from "node:crypto"; import type { IncomingHttpHeaders } from "node:http"; +import type { RequestParts } from "./parts.ts"; import { THINKING_BLOCK_TYPES } from "./streams.ts"; import type { RecordingRule } from "./types.ts"; import { @@ -27,9 +24,6 @@ import { /** Change this to make every recording a miss. */ const KEY_VERSION = "http-v1"; -/** A short hash of each part of a request, by part name. */ -export type RequestParts = Record; - /** A request as the key sees it. */ export interface KeyedRequest { /** The name of the recording file, without `.json`. */ @@ -200,39 +194,3 @@ export function keyRequest( }, }; } - -const partOrder = new Intl.Collator("en", { numeric: true }).compare; - -/** - * The candidate with the most equal parts, and the parts that differ from - * it, in a readable order. Returns `undefined` when there is no candidate. - */ -export function closestRequest( - parts: RequestParts, - candidates: Iterable, -): { candidate: T; differs: string[] } | undefined { - let best: T | undefined; - let bestEqual = -1; - for (const candidate of candidates) { - const equal = Object.keys(parts).filter( - (name) => candidate.parts[name] === parts[name], - ).length; - if (equal > bestEqual) { - bestEqual = equal; - best = candidate; - } - } - if (!best) return undefined; - const names = new Set([...Object.keys(parts), ...Object.keys(best.parts)]); - const differs = [...names] - .filter((name) => parts[name] !== best.parts[name]) - .toSorted(partOrder); - return { candidate: best, differs }; -} - -/** Name some parts, such as `messages[3], tools`. */ -export function describeParts(parts: string[]): string { - if (parts.length === 0) return "no part"; - const shown = parts.slice(0, 6).join(", "); - return parts.length > 6 ? `${shown} and ${parts.length - 6} more` : shown; -} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..8819123 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,75 @@ +/** + * Recordings, and the contract of a store that keeps them. + * + * A recording keeps the response of one request, the session (test) that + * recorded it, and the parts of the request (`parts.ts`). It does not keep + * the request body, so prompts and other inputs are not stored. + * + * A store keeps recordings by key, `/.json`. The file store + * (`recordings.ts`) keeps them in a directory that can be committed. The + * remote store (`remote-store.ts`) keeps them in the Worker (`worker/`). + * + * This file uses no Node built-ins, because the Worker uses it too. + */ +import type { RequestParts } from "./parts.ts"; + +/** One recorded response. */ +export interface Recording { + /** The session (test) that recorded the response. */ + session?: string | undefined; + request: { method: string; url: string; parts: RequestParts }; + response: { + status: number; + headers: Record; + /** + * A text body keeps each changing value of its request as a + * placeholder (`values.ts`). + */ + body: string; + /** `base64` for a body that is not text, such as an image. */ + bodyEncoding: "base64" | "utf8"; + }; +} + +/** The recording with the most equal parts, and the parts that differ. */ +export interface ClosestRecording { + key: string; + differs: string[]; +} + +/** Where the recordings of a proxy are. */ +export interface RecordingStore { + /** The recording of `key`, or `undefined` when there is none. */ + read(key: string): Promise; + /** Write recordings by key. Returns how many were new or changed. */ + write(recordings: Array<[string, Recording]>): Promise; + /** + * The recording of `rule` with the most equal parts. Recordings of the + * same session come first, because a test usually sends the same + * requests as the last time it ran. + */ + closest( + rule: string, + parts: RequestParts, + session: string | undefined, + ): Promise; + /** + * The keys that `session` recorded. Only the file store has this, + * because only it supports `usedFile` and prune. + */ + keysOf?(session: string): Promise; +} + +/** A rule name that a store accepts. It is a directory name. */ +export const RULE_NAME = /^[A-Za-z0-9][\w.-]{0,63}$/; + +/** A key that a store accepts: `/.json`. */ +export const RECORDING_KEY = /^([A-Za-z0-9][\w.-]{0,63})\/[0-9a-f]{64}\.json$/; + +/** The key of a request of `rule` with the hash `hash`. */ +export const recordingKey = (rule: string, hash: string) => + `${rule}/${hash}.json`; + +/** The text of a recording, as the stores keep it. */ +export const formatRecording = (recording: Recording) => + `${JSON.stringify(recording, null, 2)}\n`; diff --git a/src/types.ts b/src/types.ts index 909ab20..28fc8f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,10 @@ export type RecordingMode = "auto" | "off" | "record" | "replay"; /** One kind of traffic that the proxy records. */ export interface RecordingRule { - /** The name of the rule. Its recordings are in `//`. */ + /** + * The name of the rule, such as `model`. Its keys start with + * `/`. Use letters, digits, `_`, `.`, and `-`. + */ name: string; /** The requests of the rule. Each field that is set must match. */ match: { @@ -39,10 +42,23 @@ export interface RecordingRule { values?: ValuePatterns; } -/** The configuration of one proxy. */ +/** A Roach Worker (`worker/`) that keeps the recordings. */ +export interface RemoteStoreConfig { + /** The URL of the Worker, such as `https://roach.example.workers.dev`. */ + url: string; + /** + * The token of the tenant. The proxy sends it only to `url`, and never + * writes it to a recording. + */ + token: string; +} + +/** The configuration of one proxy. Set `directory` or `store`. */ export interface RoachConfig { /** The directory of the recordings. Each rule has a subdirectory. */ - directory: string; + directory?: string; + /** The Worker that keeps the recordings, in place of `directory`. */ + store?: RemoteStoreConfig; mode: RecordingMode; /** * The only origins that the proxy sends requests to, such as @@ -53,7 +69,8 @@ export interface RoachConfig { /** * When the proxy stops, it lists here the recordings that sessions used. * A failed session uses all recordings that it recorded before. Give these - * files to the `prune` command. + * files to the `prune` command. Only for `directory`: the Worker deletes + * recordings that nobody used for some time. */ usedFile?: string; /** @@ -77,9 +94,9 @@ export interface RecordingMiss { rule: string; /** The session of the request. */ session?: string | undefined; - /** The recording file that the request needed, relative to `directory`. */ + /** The key of the recording that the request needed. */ file: string; - /** The recording with the most equal parts, relative to `directory`. */ + /** The key of the recording with the most equal parts. */ closest?: string | undefined; /** The parts that differ from `closest`, such as `messages[3]`. */ differs: string[]; diff --git a/tests/worker.test.ts b/tests/worker.test.ts new file mode 100644 index 0000000..51ccac3 --- /dev/null +++ b/tests/worker.test.ts @@ -0,0 +1,237 @@ +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { ProxyAgent, request } from "undici"; +import { createTestHarness, type TestHarness } from "wrangler"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { connectRoach } from "../src/client.ts"; +import { startRoach, type RoachServer } from "../src/server.ts"; +import type { RecordingMode } from "../src/types.ts"; + +/** The parts of the Worker env that the tests read. */ +interface TestEnv { + DB: { + prepare(sql: string): { + bind(...values: unknown[]): { + all(): Promise<{ results: unknown[] }>; + }; + }; + }; + RECORDINGS: { head(key: string): Promise }; +} + +const sha256 = (text: string) => + createHash("sha256").update(text).digest("hex"); +const TOKENS = { alpha: "alpha-token", beta: "beta-token", gamma: "gamma" }; +const DAY_MS = 24 * 60 * 60 * 1000; + +let harness: TestHarness; +let workerUrl: string; +let env: TestEnv; +let upstream: Server; +let origin: string; +let liveRequests: number; +const proxies: Array<{ proxy: RoachServer; agent: ProxyAgent }> = []; + +beforeAll(async () => { + harness = createTestHarness({ + workers: [ + { + configPath: "./wrangler.jsonc", + secrets: { + ROACH_TENANTS: JSON.stringify( + Object.fromEntries( + Object.entries(TOKENS).map(([name, token]) => [ + name, + sha256(token), + ]), + ), + ), + }, + }, + ], + }); + workerUrl = (await harness.listen()).url.href; + const worker = harness.getWorker(); + await worker.applyD1Migrations("DB"); + env = (await worker.getEnv()) as TestEnv; +}, 60_000); + +afterAll(() => harness.close()); + +beforeEach(async () => { + liveRequests = 0; + upstream = createServer((incoming, outgoing) => { + liveRequests += 1; + incoming.resume(); + incoming.on("end", () => { + outgoing.writeHead(200, { "content-type": "text/event-stream" }); + outgoing.end(`data: ${liveRequests}\n\n`); + }); + }); + await new Promise((resolve) => + upstream.listen(0, "127.0.0.1", resolve), + ); + const address = upstream.address(); + if (!address || typeof address === "string") throw new Error("No port"); + origin = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + for (const { proxy, agent } of proxies.splice(0)) { + await agent.close(); + await proxy.close(); + } + await new Promise((resolve) => upstream.close(() => resolve())); +}); + +/** Start a proxy that keeps its recordings in the Worker. */ +async function start(mode: RecordingMode, token: string) { + const proxy = await startRoach({ + store: { url: workerUrl, token }, + mode, + allow: [origin], + rules: [{ name: "model", match: { method: "POST", url: `${origin}/v1/` } }], + }); + const agent = new ProxyAgent({ uri: proxy.url, proxyTunnel: true }); + proxies.push({ proxy, agent }); + return { proxy, agent }; +} + +/** Send the requests of one passed session. */ +async function session( + { proxy, agent }: Awaited>, + bodies: unknown[], +) { + const opened = await connectRoach(proxy).startSession("test"); + const sources: unknown[] = []; + for (const body of bodies) { + const response = await request(`${origin}/v1/messages`, { + body: JSON.stringify(body), + dispatcher: agent, + method: "POST", + }); + await response.body.dump(); + sources.push(response.headers["x-roach"]); + } + await opened.end(true); + return sources; +} + +/** Run a query on the D1 database of the Worker. */ +async function query(sql: string, ...values: unknown[]): Promise { + const { results } = await env.DB.prepare(sql) + .bind(...values) + .all(); + return results as Row[]; +} + +const keysOf = (tenant: string) => + query<{ key: string; last_used: number }>( + "SELECT key, last_used FROM recordings WHERE tenant = ? ORDER BY key", + tenant, + ); + +const callWorker = ( + path: string, + token: string | undefined, + init: RequestInit = {}, +) => + fetch(new URL(path, workerUrl), { + ...init, + headers: token ? { authorization: `Bearer ${token}` } : {}, + }); + +const RECORDING = { + session: "test", + request: { method: "POST", url: "https://x/", parts: { method: "a" } }, + response: { status: 200, headers: {}, body: "ok", bodyEncoding: "utf8" }, +}; + +describe("worker", () => { + it("records, replays, and finds the closest recording of a miss", async () => { + const recording = await start("auto", TOKENS.alpha); + await session(recording, [{ model: "m", messages: ["a", "b"] }]); + const [stored] = await keysOf("alpha"); + + const running = await start("replay", TOKENS.alpha); + const sources = await session(running, [ + { model: "m", messages: ["a", "b"] }, + { model: "m", messages: ["a", "c"] }, + ]); + + expect(sources).toEqual(["replayed", "missed"]); + expect(liveRequests).toBe(1); + await expect(connectRoach(running.proxy).stats()).resolves.toMatchObject({ + counts: { model: { live: 0, missed: 1, replayed: 1 } }, + misses: [{ closest: stored!.key, differs: ["messages[1]"] }], + }); + }); + + it("keeps tenants apart and refuses bad requests", async () => { + const key = `model/${"b".repeat(64)}.json`; + const put = await callWorker(`/v1/recordings/${key}`, TOKENS.alpha, { + method: "PUT", + body: JSON.stringify(RECORDING), + }); + expect(await put.json()).toEqual({ changed: true }); + + const status = async ( + path: string, + token: string | undefined, + init?: RequestInit, + ) => (await callWorker(path, token, init)).status; + expect(await status(`/v1/recordings/${key}`, TOKENS.alpha)).toBe(200); + expect(await status(`/v1/recordings/${key}`, TOKENS.beta)).toBe(404); + expect(await status(`/v1/recordings/${key}`, undefined)).toBe(401); + expect(await status(`/v1/recordings/${key}`, "wrong")).toBe(401); + expect(await status("/v1/recordings/../x.json", TOKENS.alpha)).toBe(404); + expect(await status("/v1/recordings/model/x.json", TOKENS.alpha)).toBe(400); + const putBody = (body: string) => + status(`/v1/recordings/${key}`, TOKENS.beta, { method: "PUT", body }); + expect(await putBody("{}")).toBe(400); + expect(await putBody("x".repeat(10 * 1024 * 1024 + 1))).toBe(413); + }); + + it("deletes recordings that nobody used within the TTL", async () => { + const keys = ["c", "d"].map((c) => `model/${c.repeat(64)}.json`); + for (const key of keys) { + await callWorker(`/v1/recordings/${key}`, TOKENS.gamma, { + method: "PUT", + body: JSON.stringify(RECORDING), + }); + } + const old = Date.now() - 40 * DAY_MS; + await query( + "UPDATE recordings SET last_used = ? WHERE tenant = ?", + old, + "gamma", + ); + + // A replay is a use. + await (await callWorker(`/v1/recordings/${keys[0]}`, TOKENS.gamma)).text(); + await vi.waitFor(async () => { + const rows = await keysOf("gamma"); + expect(rows[0]!.last_used).toBeGreaterThan(old); + }); + await harness + .getWorker() + .scheduled({ cron: "17 4 * * *", scheduledTime: new Date() }); + + expect((await keysOf("gamma")).map((row) => row.key)).toEqual([keys[0]]); + await expect(env.RECORDINGS.head(`gamma/${keys[1]}`)).resolves.toBeNull(); + const parts = await query<{ key: string }>( + "SELECT DISTINCT key FROM parts WHERE tenant = ?", + "gamma", + ); + expect(parts.map((row) => row.key)).toEqual([keys[0]]); + }); +}); diff --git a/worker/index.ts b/worker/index.ts new file mode 100644 index 0000000..f442dd1 --- /dev/null +++ b/worker/index.ts @@ -0,0 +1,367 @@ +/** + * The Roach Worker: a remote store for the recordings of many proxies. + * + * Each CI run still runs its own proxy. The proxy calls this Worker to read + * and write recordings (`src/remote-store.ts`). R2 keeps each recording at + * `/`. D1 keeps one row for each recording, with the time of + * its last use and the hashes of its request parts. + * + * - Each tenant has a token. `ROACH_TENANTS` keeps only the SHA-256 of + * each token. A tenant reads and writes only its own recordings. + * - A miss finds the closest recording in one D1 query on the parts index. + * It does not read the recordings themselves. + * - The scheduled handler deletes recordings that nobody used for + * `RECORDING_TTL_DAYS`. A replay or a write counts as a use. + * + * "Worker" in `README.md` has the routes and the setup. + */ +import { differingParts, type RequestParts } from "../src/parts.ts"; +import { + RECORDING_KEY, + RULE_NAME, + type ClosestRecording, + type Recording, +} from "../src/store.ts"; + +/** The bindings and variables of the Worker (`wrangler.jsonc`). */ +interface Env { + RECORDINGS: R2Bucket; + DB: D1Database; + /** JSON: the SHA-256 hex of the token of each tenant, by tenant. */ + ROACH_TENANTS?: string; + /** Days that a recording stays after its last use. */ + RECORDING_TTL_DAYS: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +/** A replay updates `last_used` only when it is older than this. */ +const TOUCH_INTERVAL_MS = DAY_MS; +const MAX_RECORDING_BYTES = 10 * 1024 * 1024; +const MAX_QUERY_BYTES = 1024 * 1024; +const MAX_PARTS = 10_000; +const MAX_PART_NAME = 512; +const MAX_SESSION = 1024; +/** Rows that one expiry batch deletes. R2 deletes at most 1000 keys. */ +const EXPIRY_BATCH = 500; +const MAX_EXPIRY_BATCHES = 20; +const TENANT_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const RECORDINGS_PATH = "/v1/recordings/"; + +/** An error for a request that the Worker refuses, with its status. */ +const refusal = (status: number, message: string) => + Object.assign(new Error(message), { name: "Refusal", status }); + +const isRefusal = (error: unknown): error is Error & { status: number } => + error instanceof Error && error.name === "Refusal"; + +const json = (body: unknown, status = 200) => + Response.json(body, { status, headers: { "cache-control": "no-store" } }); + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const hexOf = (bytes: ArrayBuffer) => + [...new Uint8Array(bytes)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + +const sha256 = async (text: string) => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + +const bytesOf = (hex: string) => + Uint8Array.from(hex.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16)); + +/** The token hashes of `ROACH_TENANTS`. A bad value throws. */ +function parseTenants(value: string | undefined): Array<[string, Uint8Array]> { + if (!value) throw new Error("ROACH_TENANTS is not set"); + const parsed: unknown = JSON.parse(value); + if (!isObject(parsed)) throw new Error("ROACH_TENANTS must be an object"); + return Object.entries(parsed).map(([name, hash]) => { + if (!TENANT_NAME.test(name) || typeof hash !== "string") { + throw new Error(`ROACH_TENANTS has a bad tenant: ${name}`); + } + if (!SHA256_HEX.test(hash)) { + throw new Error(`ROACH_TENANTS needs the SHA-256 hex of ${name}`); + } + return [name, bytesOf(hash)]; + }); +} + +/** The tenant of the bearer token of `request`. */ +async function tenantOf(request: Request, env: Env): Promise { + const tenants = parseTenants(env.ROACH_TENANTS); + const token = /^Bearer (\S+)$/.exec( + request.headers.get("authorization") ?? "", + )?.[1]; + if (token) { + const hash = new Uint8Array(await sha256(token)); + // Compare with every tenant, so the time does not show which matched. + let found: string | undefined; + for (const [name, expected] of tenants) { + if (crypto.subtle.timingSafeEqual(hash, expected)) found = name; + } + if (found) return found; + } + throw refusal(401, "bad token"); +} + +/** The body of `request` as text. A body over `limit` bytes is refused. */ +async function readBody(request: Request, limit: number): Promise { + const length = Number(request.headers.get("content-length") ?? 0); + if (length > limit) throw refusal(413, `body is over ${limit} bytes`); + const body = await request.arrayBuffer(); + if (body.byteLength > limit) { + throw refusal(413, `body is over ${limit} bytes`); + } + return new TextDecoder().decode(body); +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + throw refusal(400, "body is not JSON"); + } +} + +function checkParts(value: unknown): RequestParts { + if (!isObject(value)) throw refusal(400, "parts must be an object"); + const entries = Object.entries(value); + if (entries.length > MAX_PARTS) throw refusal(400, "too many parts"); + for (const [name, hash] of entries) { + if (name.length > MAX_PART_NAME || typeof hash !== "string") { + throw refusal(400, `bad part: ${name.slice(0, 64)}`); + } + } + return value as RequestParts; +} + +function checkSession(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string" || value.length > MAX_SESSION) { + throw refusal(400, "bad session"); + } + return value; +} + +/** Check the shape of a recording that a proxy sends. */ +function checkRecording(value: unknown): Recording { + const { request, response } = isObject(value) ? value : {}; + checkSession(isObject(value) ? value.session : undefined); + if ( + !isObject(request) || + typeof request.method !== "string" || + typeof request.url !== "string" || + !isObject(response) || + !Number.isInteger(response.status) || + !isObject(response.headers) || + typeof response.body !== "string" || + (response.bodyEncoding !== "utf8" && response.bodyEncoding !== "base64") + ) { + throw refusal(400, "body is not a recording"); + } + checkParts(request.parts); + return value as Recording; +} + +/** The key in the path of `url`, and its rule. */ +function keyOf(url: URL): { key: string; rule: string } { + const key = url.pathname.slice(RECORDINGS_PATH.length); + const match = RECORDING_KEY.exec(key); + if (!match) throw refusal(400, "bad key"); + return { key, rule: match[1]! }; +} + +async function readRecording( + env: Env, + ctx: ExecutionContext, + tenant: string, + key: string, +): Promise { + const object = await env.RECORDINGS.get(`${tenant}/${key}`); + if (!object) return json({ error: "no recording" }, 404); + const now = Date.now(); + ctx.waitUntil( + env.DB.prepare( + "UPDATE recordings SET last_used = ?1 WHERE tenant = ?2 AND key = ?3 AND last_used < ?4", + ) + .bind(now, tenant, key, now - TOUCH_INTERVAL_MS) + .run(), + ); + return new Response(object.body, { + headers: { + "content-type": "application/json", + "cache-control": "no-store", + }, + }); +} + +async function writeRecording( + request: Request, + env: Env, + tenant: string, + key: string, + rule: string, +): Promise { + const text = await readBody(request, MAX_RECORDING_BYTES); + const recording = checkRecording(parseJson(text)); + const objectKey = `${tenant}/${key}`; + const digest = hexOf(await sha256(text)); + const previous = await env.RECORDINGS.head(objectKey); + const changed = previous?.customMetadata?.digest !== digest; + // R2 first: a row always points at a recording that was written. If D1 + // then fails, the object has no row, and the R2 lifecycle rule deletes it. + if (changed) { + await env.RECORDINGS.put(objectKey, text, { + httpMetadata: { contentType: "application/json" }, + customMetadata: { digest }, + }); + } + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO recordings (tenant, key, rule, session, last_used) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT (tenant, key) DO UPDATE SET + rule = excluded.rule, + session = excluded.session, + last_used = excluded.last_used`, + ).bind(tenant, key, rule, recording.session ?? null, Date.now()), + env.DB.prepare("DELETE FROM parts WHERE tenant = ?1 AND key = ?2").bind( + tenant, + key, + ), + env.DB.prepare( + `INSERT INTO parts (tenant, key, rule, part, hash) + SELECT ?1, ?2, ?3, key, value FROM json_each(?4)`, + ).bind(tenant, key, rule, JSON.stringify(recording.request.parts)), + ]); + return json({ changed }); +} + +/** + * The recording of `rule` with the most parts equal to `parts`. Recordings + * of `session` come first. The query reads only the part rows that are + * equal, through `parts_lookup`. + */ +async function findClosest( + env: Env, + tenant: string, + rule: string, + parts: RequestParts, + session: string | null, +): Promise { + const best = await env.DB.prepare( + `WITH wanted (part, hash) AS (SELECT key, value FROM json_each(?3)) + SELECT r.key AS key + FROM wanted w + JOIN parts p + ON p.tenant = ?1 AND p.rule = ?2 AND p.part = w.part AND p.hash = w.hash + JOIN recordings r ON r.tenant = p.tenant AND r.key = p.key + GROUP BY r.key + ORDER BY MAX(?4 IS NOT NULL AND r.session = ?4) DESC, COUNT(*) DESC, r.key + LIMIT 1`, + ) + .bind(tenant, rule, JSON.stringify(parts), session) + .first<{ key: string }>(); + if (!best) return undefined; + const { results } = await env.DB.prepare( + "SELECT part, hash FROM parts WHERE tenant = ?1 AND key = ?2", + ) + .bind(tenant, best.key) + .all<{ part: string; hash: string }>(); + const theirs = Object.fromEntries(results.map((row) => [row.part, row.hash])); + return { key: best.key, differs: differingParts(parts, theirs) }; +} + +async function closest( + request: Request, + env: Env, + tenant: string, +): Promise { + const body = parseJson(await readBody(request, MAX_QUERY_BYTES)); + if (!isObject(body) || typeof body.rule !== "string") { + throw refusal(400, "rule must be a string"); + } + if (!RULE_NAME.test(body.rule)) throw refusal(400, "bad rule"); + const found = await findClosest( + env, + tenant, + body.rule, + checkParts(body.parts), + checkSession(body.session), + ); + return json({ closest: found ?? null }); +} + +async function route( + request: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + const url = new URL(request.url); + const isRecording = url.pathname.startsWith(RECORDINGS_PATH); + if (!isRecording && url.pathname !== "/v1/closest") { + return json({ error: "not found" }, 404); + } + const tenant = await tenantOf(request, env); + if (url.pathname === "/v1/closest") { + if (request.method !== "POST") return json({ error: "use POST" }, 405); + return closest(request, env, tenant); + } + const { key, rule } = keyOf(url); + switch (request.method) { + case "GET": + return readRecording(env, ctx, tenant, key); + case "PUT": + return writeRecording(request, env, tenant, key, rule); + default: + return json({ error: "use GET or PUT" }, 405); + } +} + +/** + * Delete the recordings that nobody used for `RECORDING_TTL_DAYS`, in + * batches. D1 goes first, so a recording that a replay uses during the + * delete keeps its row. Rows of `parts` go with their recording. + */ +async function expire(env: Env, now: number): Promise { + const cutoff = now - env.RECORDING_TTL_DAYS * DAY_MS; + let deleted = 0; + for (let batch = 0; batch < MAX_EXPIRY_BATCHES; batch += 1) { + const { results } = await env.DB.prepare( + `DELETE FROM recordings + WHERE last_used < ?1 AND (tenant, key) IN ( + SELECT tenant, key FROM recordings WHERE last_used < ?1 LIMIT ?2 + ) + RETURNING tenant, key`, + ) + .bind(cutoff, EXPIRY_BATCH) + .all<{ tenant: string; key: string }>(); + if (results.length === 0) break; + await env.RECORDINGS.delete( + results.map((row) => `${row.tenant}/${row.key}`), + ); + deleted += results.length; + if (results.length < EXPIRY_BATCH) break; + } + return deleted; +} + +export default { + async fetch(request, env, ctx) { + try { + return await route(request, env, ctx); + } catch (error) { + if (isRefusal(error)) { + return json({ error: error.message }, error.status); + } + throw error; + } + }, + + async scheduled(controller, env) { + const deleted = await expire(env, controller.scheduledTime); + console.log(`Deleted ${deleted} expired recordings`); + }, +} satisfies ExportedHandler; diff --git a/worker/migrations/0001_recordings.sql b/worker/migrations/0001_recordings.sql new file mode 100644 index 0000000..4bbc463 --- /dev/null +++ b/worker/migrations/0001_recordings.sql @@ -0,0 +1,28 @@ +-- One row for each recording. The recording itself is in R2, at +-- `/`. `last_used` is in milliseconds since the epoch. The +-- scheduled handler deletes rows that nobody used within the TTL. +CREATE TABLE recordings ( + tenant TEXT NOT NULL, + key TEXT NOT NULL, + rule TEXT NOT NULL, + session TEXT, + last_used INTEGER NOT NULL, + PRIMARY KEY (tenant, key) +) WITHOUT ROWID; + +CREATE INDEX recordings_last_used ON recordings (last_used); + +-- The hash of each part of each recording, for miss diagnosis. A miss +-- looks up its own parts in `parts_lookup`, so it reads only the rows that +-- are equal, not every recording of the rule. +CREATE TABLE parts ( + tenant TEXT NOT NULL, + key TEXT NOT NULL, + rule TEXT NOT NULL, + part TEXT NOT NULL, + hash TEXT NOT NULL, + PRIMARY KEY (tenant, key, part), + FOREIGN KEY (tenant, key) REFERENCES recordings (tenant, key) ON DELETE CASCADE +) WITHOUT ROWID; + +CREATE INDEX parts_lookup ON parts (tenant, rule, part, hash); diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..55da8d5 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["es2024"], + "types": ["@cloudflare/workers-types"] + }, + "include": ["."] +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..3e0cce5 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,28 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + // The Roach Worker: a remote store for recordings. See "Worker" in + // README.md. + "name": "roach", + "main": "worker/index.ts", + "compatibility_date": "2026-10-01", + "workers_dev": true, + "observability": { "enabled": true }, + "vars": { + // Days that a recording stays after its last use. + "RECORDING_TTL_DAYS": 30, + }, + // ROACH_TENANTS is a secret: `wrangler secret put ROACH_TENANTS`. + "secrets": { "required": ["ROACH_TENANTS"] }, + "r2_buckets": [ + { "binding": "RECORDINGS", "bucket_name": "roach-recordings" }, + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "roach", + "migrations_dir": "worker/migrations", + }, + ], + // Delete expired recordings once a day. + "triggers": { "crons": ["17 4 * * *"] }, +} From 6a27df786303e82cd06d4b1d00a02a9dfbfede05 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:07:37 +0000 Subject: [PATCH 02/11] fix(store): Skip files that are not rule directories in keysOf A failed session lists the keys that it recorded before. keysOf read every entry of the recordings directory as a rule directory, so a file such as .DS_Store failed with ENOTDIR and ended the session with HTTP 409. It now reads only directories. --- src/recordings.ts | 15 +++++++++------ tests/roach.test.ts | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/recordings.ts b/src/recordings.ts index 0531716..c816252 100644 --- a/src/recordings.ts +++ b/src/recordings.ts @@ -96,13 +96,16 @@ export function createFileStore(directory: string): RecordingStore { }, async keysOf(session) { - const names = await readdir(directory).catch((error: unknown) => { - if (isMissing(error)) return []; - throw error; - }); + const entries = await readdir(directory, { withFileTypes: true }).catch( + (error: unknown) => { + if (isMissing(error)) return []; + throw error; + }, + ); const keys: string[] = []; - for (const rule of names) { - for (const entry of (await indexOf(rule)).values()) { + // Each rule is a directory. Skip other files, such as `.DS_Store`. + for (const rule of entries.filter((entry) => entry.isDirectory())) { + for (const entry of (await indexOf(rule.name)).values()) { if (entry.session === session) keys.push(entry.key); } } diff --git a/tests/roach.test.ts b/tests/roach.test.ts index 8268e94..355530a 100644 --- a/tests/roach.test.ts +++ b/tests/roach.test.ts @@ -329,6 +329,8 @@ describe("roach", () => { }); it("writes nothing for a failed session", async () => { + // A file next to the rule directories is not a rule. + await writeFile(path.join(directory, ".DS_Store"), ""); const running = await start("auto"); await session(running, [{ model: "m" }], false); await session(running, [{ model: "m" }], false); From 3079d14955857b6caa3a7616a6bd039ea79f34d0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:18:08 +0000 Subject: [PATCH 03/11] feat(worker): Drop D1 and count recording use in Sentry The Worker now keeps recordings only in R2. A 30-day R2 lifecycle rule expires them, so the D1 index, the closest route, and the cron trigger are gone. The closest recording was only a hint to debug a miss; the file store still gives it. Each read and write sends the Sentry metric roach.recording with the tenant, rule, key, run, and result. The proxy sends its run in the X-Roach-Run header from the new store.run config. Grouping by key and run shows recordings that only one run uses. Co-Authored-By: David Cramer --- .dev.vars.example | 2 + AGENTS.md | 5 +- README.md | 85 +++---- package.json | 5 +- pnpm-lock.yaml | 84 ++++++- src/parts.ts | 10 +- src/recorder.ts | 2 +- src/remote-store.ts | 37 +-- src/store.ts | 7 +- src/types.ts | 6 + tests/worker.test.ts | 143 ++++++------ worker/index.ts | 314 ++++++-------------------- worker/migrations/0001_recordings.sql | 28 --- wrangler.jsonc | 17 +- 14 files changed, 316 insertions(+), 429 deletions(-) delete mode 100644 worker/migrations/0001_recordings.sql diff --git a/.dev.vars.example b/.dev.vars.example index 900c8d6..241d1b8 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,3 +1,5 @@ # Copy to .dev.vars for `wrangler dev`. The value is the SHA-256 hex of each # tenant token. This one is the hash of "local-token". ROACH_TENANTS={"local":"c7ec7c548f5992a239dc52fea748b808b408fe083c4625e9aa6ffe717343f9c1"} +# Optional: send the roach.recording metrics to Sentry. +# SENTRY_DSN=https://@.ingest.sentry.io/ diff --git a/AGENTS.md b/AGENTS.md index 8a6ef75..88ba0f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,9 +29,8 @@ ## Key Conventions -- Add no runtime dependencies. Use Node built-ins and the `openssl` command only. -- `worker/` runs on Cloudflare Workers, with `worker/tsconfig.json`. The `src/` files that it imports (`parts.ts`, `store.ts`) must not use Node built-ins. -- Change the D1 schema only with a new file in `worker/migrations/`. +- The proxy (`src/`) has no runtime dependencies. Use Node built-ins and the `openssl` command only. The only runtime dependency is `@sentry/cloudflare`, for `worker/`. +- `worker/` runs on Cloudflare Workers, with `worker/tsconfig.json`. The `src/` files that it imports (`store.ts`) must not use Node built-ins. - Node runs `src/` as TypeScript with type stripping. Use only erasable syntax, and import local files with the `.ts` extension. - Use functions and plain objects, not classes. - Start each source file with a comment that says what the file owns. Give each export a short JSDoc. diff --git a/README.md b/README.md index 9daf275..e1f2b94 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,17 @@ it rules and tells it when each test starts and ends. The recordings are in one of two stores: - **Files** (`directory`): JSON files that you commit to git. -- **A Roach Worker** (`store`): a Cloudflare Worker with R2 and D1. Many - repositories and CI runs can use it. It deletes recordings that nobody - used for 30 days. See [Worker](#worker). +- **A Roach Worker** (`store`): a Cloudflare Worker with R2. Many + repositories and CI runs can use it. R2 deletes each recording 30 days + after it was written. See [Worker](#worker). The proxy always runs in the process tree of the test run. Only the recordings move to the Worker. Roach started in [`getsentry/junior`](https://github.com/getsentry/junior/tree/main/packages/junior-evals/src/roach). -The proxy uses only Node built-ins and the `openssl` command. +The proxy uses only Node built-ins and the `openssl` command. The Worker +uses the Sentry SDK (`@sentry/cloudflare`). ## Use @@ -65,9 +66,11 @@ await proxy.close(); `src/types.ts` has the full types. - `directory`: where the recordings are. Each rule has a subdirectory. -- `store`: `{ url, token }` of a Roach Worker, in place of `directory`. - Set one of the two. The proxy calls the Worker directly, and never writes - the token to a recording. +- `store`: `{ url, token, run }` of a Roach Worker, in place of + `directory`. Set one of the two. The proxy calls the Worker directly, and + never writes the token to a recording. `run` is optional. It names the CI + run in the Sentry metrics of the Worker, such as + `${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`. - `mode`: `auto` replays recordings and records misses. `replay` replays recordings and fails a miss with HTTP 412, so nothing goes live. `record` sends every request live and records it again. `off` records and replays @@ -158,17 +161,16 @@ real way use the same recording. The parts of a request are the method, the URL, each key header, each top-level field of a JSON body, and each item of a top-level array, such as -`messages[3]`. For a request without a recording, the proxy finds the -recording with the most equal parts, from the same session if it can. It -logs the parts that differ and adds the miss to `stats().misses`. -`describeRecordingMisses()` in `report.ts` gives the first miss of each -test, which is the one to fix. - -- The file store reads all recordings of a rule on the first miss of that - rule in a run, and then keeps them in memory. -- The Worker keeps the hash of each part in a D1 index. A miss is one - query that reads only the rows with an equal part. It does not read the - recordings in R2. +`messages[3]`. The proxy adds each request without a recording to +`stats().misses`. `describeRecordingMisses()` in `report.ts` gives the +first miss of each test, which is the one to fix. + +With the file store, the proxy also finds the recording with the most +equal parts, from the same session if it can, and logs the parts that +differ. This is only a hint to debug a miss. It never makes a replay. To +find it, the file store reads all recordings of a rule on the first miss of +that rule in a run, and then keeps them in memory. The Worker does not give +this hint, because it keeps no index of the parts. ## Command line @@ -198,67 +200,70 @@ All calls need `Authorization: Bearer `. `client.ts` calls them. `wrangler.jsonc` is its config. - **R2** (`RECORDINGS`) keeps each recording at `/`. -- **D1** (`DB`) keeps one row for each recording: its rule, its session, - the time of its last use, and the hash of each request part. - **Tenants**: each tenant has a token. The secret `ROACH_TENANTS` maps each tenant name to the SHA-256 hex of its token. A tenant sees only its own recordings. Give each repository its own tenant. Tenants do not share recordings, because a key does not include credentials: a shared key would replay one tenant's private response to another. -- **Expiry**: a replay or a write sets the time of last use. A replay - writes to D1 at most once a day for each recording. Once a day, the cron - trigger deletes recordings that nobody used for `RECORDING_TTL_DAYS` - (30). It deletes up to 10,000 in each run. +- **Expiry**: an R2 lifecycle rule deletes each recording 30 days after it + was written. A replay does not extend this time, and a write of the same + recording does not either. So a recording that replays each day still + expires, and its next request goes live in `auto` mode. In `replay` mode, + that request fails with HTTP 412. R2 can take up to a day more to delete + an expired recording. +- **Sentry**: with the secret `SENTRY_DSN`, each read and write sends the + metric `roach.recording`. Its attributes are `tenant`, `rule`, `key`, + `run`, and `result`: `replayed`, `missed`, `written`, or `unchanged`. + Group by `key` and `run` to find recordings that only one run uses. Those + never replay, which often means a value that changes on each run. The + `key` and `run` attributes have a different value for each recording and + run. ### Set up ```sh pnpm exec wrangler r2 bucket create roach-recordings -# Add a backstop for objects that lost their D1 row (see Limits). -pnpm exec wrangler r2 bucket lifecycle add roach-recordings backstop --expire-days 180 -pnpm exec wrangler d1 create roach # put the database_id in wrangler.jsonc +pnpm exec wrangler r2 bucket lifecycle add roach-recordings expire --expire-days 30 # Each tenant token is a random secret. Keep only its hash in the Worker. token=$(openssl rand -hex 32); hash=$(printf %s "$token" | sha256sum | cut -d' ' -f1) printf '{"junior":"%s"}' "$hash" | pnpm exec wrangler secret put ROACH_TENANTS -pnpm worker:deploy # apply D1 migrations, then deploy +pnpm exec wrangler secret put SENTRY_DSN # optional +pnpm worker:deploy ``` Then give the proxy `store: { url: "https://roach..workers.dev", -token }`. Keep the token in a CI secret. +token, run }`. Keep the token in a CI secret. ### Routes All routes need `Authorization: Bearer `. `src/remote-store.ts` -calls them. +calls them. The optional header `X-Roach-Run` names the run in the Sentry +metrics. - `GET /v1/recordings/`: the recording, or HTTP 404. - `PUT /v1/recordings/` with the recording: write it. Returns `{"changed": true}` when it was new or changed. A body over 10 MiB gets HTTP 413. -- `POST /v1/closest` with `{"rule", "parts", "session"}`: returns - `{"closest": {"key", "differs"}}`, or `{"closest": null}`. A bad token gets HTTP 401. A bad key or body gets HTTP 400. ### Limits -- A write puts the R2 object first, then the D1 row. If D1 fails, the - object has no row, so the cron trigger cannot delete it. The R2 - lifecycle rule above deletes it later. - A tenant can write as many recordings as it wants. There is no quota. - To add or remove a tenant, change `ROACH_TENANTS`. +- Without `SENTRY_DSN`, the Worker sends nothing to Sentry. ### Run it locally ```sh cp .dev.vars.example .dev.vars # the tenant "local" with token "local-token" -pnpm exec wrangler d1 migrations apply DB --local pnpm worker:dev ``` `tests/worker.test.ts` runs the Worker from `wrangler.jsonc` with local R2 -and D1 (`createTestHarness` of Wrangler). It tests record and replay -through a real proxy, misses, tenants, limits, and expiry. +(`createTestHarness` of Wrangler) and a local Sentry server. It tests +record and replay through a real proxy, misses, tenants, limits, and the +Sentry metrics. ## Development @@ -281,7 +286,7 @@ The proxy is in `src/`. The Worker is in `worker/`. Tests are in `tests/`. - `store.ts`: the recording format and the store contract. - `recordings.ts`: the file store and prune. - `remote-store.ts`: the store that calls the Worker. -- `parts.ts`: compares the parts of requests for miss diagnosis. +- `parts.ts`: compares the parts of requests to debug a miss. - `values.ts`: changing values and their placeholders. - `streams.ts`: merges the deltas of a recorded model stream. - `certificates.ts`: the certificate authority for HTTPS. @@ -289,4 +294,4 @@ The proxy is in `src/`. The Worker is in `worker/`. Tests are in `tests/`. API. - `report.ts`: text reports of a run. - `cli.ts`: the command line. -- `worker/index.ts`: the Worker. `worker/migrations/` has the D1 schema. +- `worker/index.ts`: the Worker. diff --git a/package.json b/package.json index d3f4522..a23469e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,10 @@ "test:coverage": "vitest run --coverage", "typecheck": "tsc -p . && tsc -p worker", "worker:dev": "wrangler dev", - "worker:deploy": "wrangler d1 migrations apply DB --remote && wrangler deploy" + "worker:deploy": "wrangler deploy" + }, + "dependencies": { + "@sentry/cloudflare": "11.6.0" }, "devDependencies": { "@cloudflare/workers-types": "^5.20261008.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8d1aa7..8fcbf3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@sentry/cloudflare': + specifier: 11.6.0 + version: 11.6.0(@cloudflare/workers-types@5.20261008.1)(wrangler@4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1)) devDependencies: '@cloudflare/workers-types': specifier: ^5.20261008.1 @@ -46,7 +50,7 @@ importers: version: 8.11.2 vitest: specifier: ^5.0.3 - version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) wrangler: specifier: ^4.149.0 version: 4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1) @@ -482,6 +486,10 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.150.0': resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1095,6 +1103,26 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sentry/cloudflare@11.6.0': + resolution: {integrity: sha512-FOwluoZmgE60JLxQBKUw0smiz3FaVsmtuvslkgLb+9pUbxGsjnLf1RX8F99vPSCB724Q916asImCHTlWRynUSQ==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + peerDependencies: + '@cloudflare/workers-types': ^4.x || ^5.x + wrangler: ^4.x + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrangler: + optional: true + + '@sentry/conventions@0.26.0': + resolution: {integrity: sha512-ttSRcoLf2kIENDIFzH12aJaVMdxnidMYLvVNwEX/JjXfXaQot8u5M47QmgAFOOhVJ4nQ2uU8wTCh+fwSPrrCfg==} + engines: {node: '>=14'} + + '@sentry/core@11.6.0': + resolution: {integrity: sha512-PbITT1kfJYKJiS9fH4ZtOcwIONKyt+tUsTZSPtcAjPdkUDOzMKT61MkUa1Ze07VzNtLALT24ZdUOc6d9K4YoKA==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + '@sentry/dotagents-lib@3.3.0': resolution: {integrity: sha512-A/venGOGqDKqGTEb9Iw9785Q+JlCUhmEfa6YfJ1lu0lI7fz1LiMVgjD750ajm7Z1ymhg5clktwnMqUEvj/iMQA==} engines: {node: '>=20'} @@ -1104,6 +1132,16 @@ packages: engines: {node: '>=20'} hasBin: true + '@sentry/opentelemetry@11.6.0': + resolution: {integrity: sha512-W6b+/8TVejHSCSOWMGsU2wVQY+d7FH/kI2zIFYXmIcxJCd7A/ULhOXe29UYeh+VX9wObR23LvEcEUmgsFVqf6A==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@sentry/server-utils@11.6.0': + resolution: {integrity: sha512-pVpGqwxBOXDMSngxTVARRlYyEXEfKA3WU1ENCdgLCeJ4xJ4vNRf4ODGXYPvXBk0zkt2NZZFvray2JHXIeYG+tg==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1453,6 +1491,9 @@ packages: engines: {node: '>=22.22.1'} hasBin: true + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -2045,6 +2086,8 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@opentelemetry/api@1.9.1': {} + '@oxc-parser/binding-android-arm-eabi@0.150.0': optional: true @@ -2358,6 +2401,24 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sentry/cloudflare@11.6.0(@cloudflare/workers-types@5.20261008.1)(wrangler@4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/conventions': 0.26.0 + '@sentry/core': 11.6.0 + '@sentry/opentelemetry': 11.6.0(@opentelemetry/api@1.9.1) + '@sentry/server-utils': 11.6.0 + magic-string: 0.30.21 + optionalDependencies: + '@cloudflare/workers-types': 5.20261008.1 + wrangler: 4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1) + + '@sentry/conventions@0.26.0': {} + + '@sentry/core@11.6.0': + dependencies: + '@sentry/conventions': 0.26.0 + '@sentry/dotagents-lib@3.3.0': dependencies: yaml: 2.9.1 @@ -2371,6 +2432,18 @@ snapshots: smol-toml: 1.9.0 zod: 4.6.5 + '@sentry/opentelemetry@11.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/conventions': 0.26.0 + '@sentry/core': 11.6.0 + + '@sentry/server-utils@11.6.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/conventions': 0.26.0 + '@sentry/core': 11.6.0 + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.24': {} @@ -2463,7 +2536,7 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -2650,6 +2723,10 @@ snapshots: optionalDependencies: yaml: 2.9.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -2934,7 +3011,7 @@ snapshots: jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) @@ -2951,6 +3028,7 @@ snapshots: vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 24.19.1 '@vitest/coverage-v8': 5.0.3(vitest@5.0.3) transitivePeerDependencies: diff --git a/src/parts.ts b/src/parts.ts index c643817..01e4aa4 100644 --- a/src/parts.ts +++ b/src/parts.ts @@ -4,11 +4,9 @@ * The parts are the method, the URL, each key header, each top-level field * of a JSON body, and each item of a top-level array, such as * `messages[3]`. A recording keeps a short hash of each part - * (`request-key.ts`). When a request has no recording, the store finds the - * recording with the most equal parts and reports the parts that differ. - * - * This file uses no Node built-ins, because the Worker (`worker/`) uses it - * too. + * (`request-key.ts`). When a request has no recording, the file store + * finds the recording with the most equal parts and reports the parts that + * differ. This only helps a person debug a miss. It never makes a replay. */ /** A short hash of each part of a request, by part name. */ @@ -21,7 +19,7 @@ const countEqualParts = (parts: RequestParts, other: RequestParts) => Object.keys(parts).filter((name) => other[name] === parts[name]).length; /** The parts that are in only one of `a` and `b`, or differ, in order. */ -export function differingParts(a: RequestParts, b: RequestParts): string[] { +function differingParts(a: RequestParts, b: RequestParts): string[] { const names = new Set([...Object.keys(a), ...Object.keys(b)]); return [...names].filter((name) => a[name] !== b[name]).toSorted(partOrder); } diff --git a/src/recorder.ts b/src/recorder.ts index 815323a..6e3e33f 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -228,7 +228,7 @@ export function createRecorder(config: RoachConfig) { keyed: KeyedRequest, owner: Session | undefined, ) => { - const closest = await store.closest(rule.name, keyed.parts, owner?.name); + const closest = await store.closest?.(rule.name, keyed.parts, owner?.name); const miss: RecordingMiss = { rule: rule.name, session: owner?.name, diff --git a/src/remote-store.ts b/src/remote-store.ts index 1c05ea5..7307965 100644 --- a/src/remote-store.ts +++ b/src/remote-store.ts @@ -2,14 +2,12 @@ * The remote store: the recordings are in a Roach Worker (`worker/`). * * The proxy calls the Worker directly, not through itself, with the token - * of its tenant. The Worker finds the closest recording of a miss in a - * database query, so a miss costs one call and not one read per recording. - * "Worker API" in `README.md` has the routes. + * of its tenant and the name of its run. "Worker" in `README.md` has the + * routes. A miss has no closest recording, because the Worker keeps no + * index of request parts. */ -import type { RequestParts } from "./parts.ts"; import { formatRecording, - type ClosestRecording, type Recording, type RecordingStore, } from "./store.ts"; @@ -38,14 +36,15 @@ export function createRemoteStore(config: RemoteStoreConfig): RecordingStore { const base = parseBase(config.url); const call = async ( - method: "GET" | "POST" | "PUT", - route: string, + method: "GET" | "PUT", + key: string, body?: string, ): Promise => { - const response = await fetch(new URL(`v1/${route}`, base), { + const response = await fetch(new URL(`v1/recordings/${key}`, base), { method, headers: { authorization: `Bearer ${config.token}`, + ...(config.run === undefined ? {} : { "x-roach-run": config.run }), ...(body === undefined ? {} : { "content-type": "application/json" }), }, ...(body === undefined ? {} : { body }), @@ -53,24 +52,20 @@ export function createRemoteStore(config: RemoteStoreConfig): RecordingStore { }); if (!response.ok && !(method === "GET" && response.status === 404)) { throw new Error( - `Roach store ${method} ${route} failed with HTTP ${response.status}: ${await response.text()}`, + `Roach store ${method} ${key} failed with HTTP ${response.status}: ${await response.text()}`, ); } return response; }; const writeOne = async ([key, recording]: [string, Recording]) => { - const response = await call( - "PUT", - `recordings/${key}`, - formatRecording(recording), - ); + const response = await call("PUT", key, formatRecording(recording)); return ((await response.json()) as { changed: boolean }).changed; }; return { async read(key) { - const response = await call("GET", `recordings/${key}`); + const response = await call("GET", key); if (response.status === 404) { await response.body?.cancel(); return undefined; @@ -88,17 +83,5 @@ export function createRemoteStore(config: RemoteStoreConfig): RecordingStore { } return changed; }, - - async closest(rule: string, parts: RequestParts, session) { - const response = await call( - "POST", - "closest", - JSON.stringify({ rule, parts, session: session ?? null }), - ); - const { closest } = (await response.json()) as { - closest: ClosestRecording | null; - }; - return closest ?? undefined; - }, }; } diff --git a/src/store.ts b/src/store.ts index 8819123..f2c0918 100644 --- a/src/store.ts +++ b/src/store.ts @@ -32,7 +32,7 @@ export interface Recording { } /** The recording with the most equal parts, and the parts that differ. */ -export interface ClosestRecording { +interface ClosestRecording { key: string; differs: string[]; } @@ -46,9 +46,10 @@ export interface RecordingStore { /** * The recording of `rule` with the most equal parts. Recordings of the * same session come first, because a test usually sends the same - * requests as the last time it ran. + * requests as the last time it ran. Only the file store has this: it is a + * hint for debugging a miss, and the Worker keeps no index for it. */ - closest( + closest?( rule: string, parts: RequestParts, session: string | undefined, diff --git a/src/types.ts b/src/types.ts index 28fc8f8..e5106f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,6 +51,12 @@ export interface RemoteStoreConfig { * writes it to a recording. */ token: string; + /** + * The run of this proxy, such as the GitHub Actions run id. The Worker + * counts the runs that use each recording, so a recording that only one + * run uses shows up in Sentry. + */ + run?: string; } /** The configuration of one proxy. Set `directory` or `store`. */ diff --git a/tests/worker.test.ts b/tests/worker.test.ts index 51ccac3..a0f5344 100644 --- a/tests/worker.test.ts +++ b/tests/worker.test.ts @@ -10,30 +10,35 @@ import { describe, expect, it, - vi, } from "vitest"; import { connectRoach } from "../src/client.ts"; import { startRoach, type RoachServer } from "../src/server.ts"; import type { RecordingMode } from "../src/types.ts"; -/** The parts of the Worker env that the tests read. */ +/** The part of the Worker env that the tests read. */ interface TestEnv { - DB: { - prepare(sql: string): { - bind(...values: unknown[]): { - all(): Promise<{ results: unknown[] }>; - }; - }; + RECORDINGS: { + list(options: { prefix: string }): Promise<{ + objects: Array<{ key: string }>; + }>; }; - RECORDINGS: { head(key: string): Promise }; } const sha256 = (text: string) => createHash("sha256").update(text).digest("hex"); -const TOKENS = { alpha: "alpha-token", beta: "beta-token", gamma: "gamma" }; -const DAY_MS = 24 * 60 * 60 * 1000; +const TOKENS = { alpha: "alpha-token", beta: "beta-token" }; + +/** One `roach.recording` metric that the Worker sent to Sentry. */ +interface RecordingMetric { + key: string; + result: string; + run: string; + tenant: string; +} let harness: TestHarness; +let sentry: Server; +const metrics: RecordingMetric[] = []; let workerUrl: string; let env: TestEnv; let upstream: Server; @@ -41,7 +46,40 @@ let origin: string; let liveRequests: number; const proxies: Array<{ proxy: RoachServer; agent: ProxyAgent }> = []; +/** Keep the `roach.recording` metrics of a Sentry envelope. */ +function readEnvelope(envelope: string) { + for (const line of envelope.split("\n")) { + if (!line.includes('"roach.recording"')) continue; + const { items } = JSON.parse(line) as { + items: Array<{ attributes: Record }>; + }; + for (const { attributes } of items) { + metrics.push({ + key: attributes.key?.value ?? "", + result: attributes.result?.value ?? "", + run: attributes.run?.value ?? "", + tenant: attributes.tenant?.value ?? "", + }); + } + } +} + beforeAll(async () => { + // A local Sentry that keeps the metrics of the Worker. + sentry = createServer((incoming, outgoing) => { + let envelope = ""; + incoming.setEncoding("utf8"); + incoming.on("data", (chunk: string) => (envelope += chunk)); + incoming.on("end", () => { + readEnvelope(envelope); + outgoing.end("{}"); + }); + }); + await new Promise((resolve) => sentry.listen(0, "127.0.0.1", resolve)); + const sentryAddress = sentry.address(); + if (!sentryAddress || typeof sentryAddress === "string") { + throw new Error("No port"); + } harness = createTestHarness({ workers: [ { @@ -55,17 +93,19 @@ beforeAll(async () => { ]), ), ), + SENTRY_DSN: `http://public@127.0.0.1:${sentryAddress.port}/1`, }, }, ], }); workerUrl = (await harness.listen()).url.href; - const worker = harness.getWorker(); - await worker.applyD1Migrations("DB"); - env = (await worker.getEnv()) as TestEnv; + env = (await harness.getWorker().getEnv()) as TestEnv; }, 60_000); -afterAll(() => harness.close()); +afterAll(async () => { + await harness.close(); + await new Promise((resolve) => sentry.close(() => resolve())); +}); beforeEach(async () => { liveRequests = 0; @@ -96,7 +136,7 @@ afterEach(async () => { /** Start a proxy that keeps its recordings in the Worker. */ async function start(mode: RecordingMode, token: string) { const proxy = await startRoach({ - store: { url: workerUrl, token }, + store: { url: workerUrl, token, run: "run-1" }, mode, allow: [origin], rules: [{ name: "model", match: { method: "POST", url: `${origin}/v1/` } }], @@ -126,18 +166,10 @@ async function session( return sources; } -/** Run a query on the D1 database of the Worker. */ -async function query(sql: string, ...values: unknown[]): Promise { - const { results } = await env.DB.prepare(sql) - .bind(...values) - .all(); - return results as Row[]; -} - -const keysOf = (tenant: string) => - query<{ key: string; last_used: number }>( - "SELECT key, last_used FROM recordings WHERE tenant = ? ORDER BY key", - tenant, +/** The keys that `tenant` has in R2. */ +const keysOf = async (tenant: string) => + (await env.RECORDINGS.list({ prefix: `${tenant}/` })).objects.map((object) => + object.key.slice(tenant.length + 1), ); const callWorker = ( @@ -157,10 +189,10 @@ const RECORDING = { }; describe("worker", () => { - it("records, replays, and finds the closest recording of a miss", async () => { + it("records, replays, and fails a miss in replay mode", async () => { const recording = await start("auto", TOKENS.alpha); await session(recording, [{ model: "m", messages: ["a", "b"] }]); - const [stored] = await keysOf("alpha"); + const stored = await keysOf("alpha"); const running = await start("replay", TOKENS.alpha); const sources = await session(running, [ @@ -170,9 +202,24 @@ describe("worker", () => { expect(sources).toEqual(["replayed", "missed"]); expect(liveRequests).toBe(1); + expect(stored).toHaveLength(1); + // Each read and write of the recording is one metric in Sentry. + await expect + .poll(() => metrics.filter((metric) => metric.key === stored[0])) + .toEqual( + expect.arrayContaining( + ["missed", "written", "replayed"].map((result) => ({ + key: stored[0], + result, + run: "run-1", + tenant: "alpha", + })), + ), + ); + // The Worker keeps no index, so a miss has no closest recording. await expect(connectRoach(running.proxy).stats()).resolves.toMatchObject({ counts: { model: { live: 0, missed: 1, replayed: 1 } }, - misses: [{ closest: stored!.key, differs: ["messages[1]"] }], + misses: [{ file: expect.stringMatching(/^model\//), differs: [] }], }); }); @@ -200,38 +247,4 @@ describe("worker", () => { expect(await putBody("{}")).toBe(400); expect(await putBody("x".repeat(10 * 1024 * 1024 + 1))).toBe(413); }); - - it("deletes recordings that nobody used within the TTL", async () => { - const keys = ["c", "d"].map((c) => `model/${c.repeat(64)}.json`); - for (const key of keys) { - await callWorker(`/v1/recordings/${key}`, TOKENS.gamma, { - method: "PUT", - body: JSON.stringify(RECORDING), - }); - } - const old = Date.now() - 40 * DAY_MS; - await query( - "UPDATE recordings SET last_used = ? WHERE tenant = ?", - old, - "gamma", - ); - - // A replay is a use. - await (await callWorker(`/v1/recordings/${keys[0]}`, TOKENS.gamma)).text(); - await vi.waitFor(async () => { - const rows = await keysOf("gamma"); - expect(rows[0]!.last_used).toBeGreaterThan(old); - }); - await harness - .getWorker() - .scheduled({ cron: "17 4 * * *", scheduledTime: new Date() }); - - expect((await keysOf("gamma")).map((row) => row.key)).toEqual([keys[0]]); - await expect(env.RECORDINGS.head(`gamma/${keys[1]}`)).resolves.toBeNull(); - const parts = await query<{ key: string }>( - "SELECT DISTINCT key FROM parts WHERE tenant = ?", - "gamma", - ); - expect(parts.map((row) => row.key)).toEqual([keys[0]]); - }); }); diff --git a/worker/index.ts b/worker/index.ts index f442dd1..3dd8614 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -3,49 +3,37 @@ * * Each CI run still runs its own proxy. The proxy calls this Worker to read * and write recordings (`src/remote-store.ts`). R2 keeps each recording at - * `/`. D1 keeps one row for each recording, with the time of - * its last use and the hashes of its request parts. + * `/`. An R2 lifecycle rule deletes recordings 30 days after + * they were written, so nothing here deletes or indexes recordings. * * - Each tenant has a token. `ROACH_TENANTS` keeps only the SHA-256 of * each token. A tenant reads and writes only its own recordings. - * - A miss finds the closest recording in one D1 query on the parts index. - * It does not read the recordings themselves. - * - The scheduled handler deletes recordings that nobody used for - * `RECORDING_TTL_DAYS`. A replay or a write counts as a use. - * - * "Worker" in `README.md` has the routes and the setup. + * - Each read and write sends the Sentry metric `roach.recording`, with the + * tenant, the key, the result, and the run of the proxy. A key that only + * one run uses is a recording that never replays. See "Worker" in + * `README.md`. */ -import { differingParts, type RequestParts } from "../src/parts.ts"; -import { - RECORDING_KEY, - RULE_NAME, - type ClosestRecording, - type Recording, -} from "../src/store.ts"; +import * as Sentry from "@sentry/cloudflare"; +import { RECORDING_KEY } from "../src/store.ts"; /** The bindings and variables of the Worker (`wrangler.jsonc`). */ interface Env { RECORDINGS: R2Bucket; - DB: D1Database; /** JSON: the SHA-256 hex of the token of each tenant, by tenant. */ ROACH_TENANTS?: string; - /** Days that a recording stays after its last use. */ - RECORDING_TTL_DAYS: number; + /** Without a DSN, the Worker sends nothing to Sentry. */ + SENTRY_DSN?: string; } -const DAY_MS = 24 * 60 * 60 * 1000; -/** A replay updates `last_used` only when it is older than this. */ -const TOUCH_INTERVAL_MS = DAY_MS; +/** What a request did with a recording. */ +type Result = "replayed" | "missed" | "written" | "unchanged"; + const MAX_RECORDING_BYTES = 10 * 1024 * 1024; -const MAX_QUERY_BYTES = 1024 * 1024; -const MAX_PARTS = 10_000; -const MAX_PART_NAME = 512; -const MAX_SESSION = 1024; -/** Rows that one expiry batch deletes. R2 deletes at most 1000 keys. */ -const EXPIRY_BATCH = 500; -const MAX_EXPIRY_BATCHES = 20; const TENANT_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; const SHA256_HEX = /^[0-9a-f]{64}$/; +/** The run header that the proxy sends, such as a GitHub Actions run id. */ +const RUN_HEADER = "x-roach-run"; +const MAX_RUN = 128; const RECORDINGS_PATH = "/v1/recordings/"; /** An error for a request that the Worker refuses, with its status. */ @@ -106,49 +94,26 @@ async function tenantOf(request: Request, env: Env): Promise { throw refusal(401, "bad token"); } -/** The body of `request` as text. A body over `limit` bytes is refused. */ -async function readBody(request: Request, limit: number): Promise { +/** The body of `request` as text. A body over the limit is refused. */ +async function readBody(request: Request): Promise { + const tooLarge = () => + refusal(413, `body is over ${MAX_RECORDING_BYTES} bytes`); const length = Number(request.headers.get("content-length") ?? 0); - if (length > limit) throw refusal(413, `body is over ${limit} bytes`); + if (length > MAX_RECORDING_BYTES) throw tooLarge(); const body = await request.arrayBuffer(); - if (body.byteLength > limit) { - throw refusal(413, `body is over ${limit} bytes`); - } + if (body.byteLength > MAX_RECORDING_BYTES) throw tooLarge(); return new TextDecoder().decode(body); } -function parseJson(text: string): unknown { +/** Refuse a body that is not a recording (`src/store.ts`). */ +function checkRecording(text: string): void { + let value: unknown; try { - return JSON.parse(text); + value = JSON.parse(text); } catch { throw refusal(400, "body is not JSON"); } -} - -function checkParts(value: unknown): RequestParts { - if (!isObject(value)) throw refusal(400, "parts must be an object"); - const entries = Object.entries(value); - if (entries.length > MAX_PARTS) throw refusal(400, "too many parts"); - for (const [name, hash] of entries) { - if (name.length > MAX_PART_NAME || typeof hash !== "string") { - throw refusal(400, `bad part: ${name.slice(0, 64)}`); - } - } - return value as RequestParts; -} - -function checkSession(value: unknown): string | null { - if (value === undefined || value === null) return null; - if (typeof value !== "string" || value.length > MAX_SESSION) { - throw refusal(400, "bad session"); - } - return value; -} - -/** Check the shape of a recording that a proxy sends. */ -function checkRecording(value: unknown): Recording { const { request, response } = isObject(value) ? value : {}; - checkSession(isObject(value) ? value.session : undefined); if ( !isObject(request) || typeof request.method !== "string" || @@ -161,197 +126,73 @@ function checkRecording(value: unknown): Recording { ) { throw refusal(400, "body is not a recording"); } - checkParts(request.parts); - return value as Recording; } -/** The key in the path of `url`, and its rule. */ -function keyOf(url: URL): { key: string; rule: string } { - const key = url.pathname.slice(RECORDINGS_PATH.length); - const match = RECORDING_KEY.exec(key); - if (!match) throw refusal(400, "bad key"); - return { key, rule: match[1]! }; -} - -async function readRecording( - env: Env, - ctx: ExecutionContext, - tenant: string, - key: string, -): Promise { - const object = await env.RECORDINGS.get(`${tenant}/${key}`); - if (!object) return json({ error: "no recording" }, 404); - const now = Date.now(); - ctx.waitUntil( - env.DB.prepare( - "UPDATE recordings SET last_used = ?1 WHERE tenant = ?2 AND key = ?3 AND last_used < ?4", - ) - .bind(now, tenant, key, now - TOUCH_INTERVAL_MS) - .run(), - ); - return new Response(object.body, { - headers: { - "content-type": "application/json", - "cache-control": "no-store", - }, - }); -} - -async function writeRecording( - request: Request, - env: Env, +/** Count one read or write of a recording in Sentry. */ +function countUse( tenant: string, key: string, - rule: string, -): Promise { - const text = await readBody(request, MAX_RECORDING_BYTES); - const recording = checkRecording(parseJson(text)); - const objectKey = `${tenant}/${key}`; - const digest = hexOf(await sha256(text)); - const previous = await env.RECORDINGS.head(objectKey); - const changed = previous?.customMetadata?.digest !== digest; - // R2 first: a row always points at a recording that was written. If D1 - // then fails, the object has no row, and the R2 lifecycle rule deletes it. - if (changed) { - await env.RECORDINGS.put(objectKey, text, { - httpMetadata: { contentType: "application/json" }, - customMetadata: { digest }, - }); - } - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO recordings (tenant, key, rule, session, last_used) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT (tenant, key) DO UPDATE SET - rule = excluded.rule, - session = excluded.session, - last_used = excluded.last_used`, - ).bind(tenant, key, rule, recording.session ?? null, Date.now()), - env.DB.prepare("DELETE FROM parts WHERE tenant = ?1 AND key = ?2").bind( + run: string | null, + result: Result, +) { + Sentry.metrics.count("roach.recording", 1, { + attributes: { tenant, + rule: key.slice(0, key.indexOf("/")), key, - ), - env.DB.prepare( - `INSERT INTO parts (tenant, key, rule, part, hash) - SELECT ?1, ?2, ?3, key, value FROM json_each(?4)`, - ).bind(tenant, key, rule, JSON.stringify(recording.request.parts)), - ]); - return json({ changed }); -} - -/** - * The recording of `rule` with the most parts equal to `parts`. Recordings - * of `session` come first. The query reads only the part rows that are - * equal, through `parts_lookup`. - */ -async function findClosest( - env: Env, - tenant: string, - rule: string, - parts: RequestParts, - session: string | null, -): Promise { - const best = await env.DB.prepare( - `WITH wanted (part, hash) AS (SELECT key, value FROM json_each(?3)) - SELECT r.key AS key - FROM wanted w - JOIN parts p - ON p.tenant = ?1 AND p.rule = ?2 AND p.part = w.part AND p.hash = w.hash - JOIN recordings r ON r.tenant = p.tenant AND r.key = p.key - GROUP BY r.key - ORDER BY MAX(?4 IS NOT NULL AND r.session = ?4) DESC, COUNT(*) DESC, r.key - LIMIT 1`, - ) - .bind(tenant, rule, JSON.stringify(parts), session) - .first<{ key: string }>(); - if (!best) return undefined; - const { results } = await env.DB.prepare( - "SELECT part, hash FROM parts WHERE tenant = ?1 AND key = ?2", - ) - .bind(tenant, best.key) - .all<{ part: string; hash: string }>(); - const theirs = Object.fromEntries(results.map((row) => [row.part, row.hash])); - return { key: best.key, differs: differingParts(parts, theirs) }; -} - -async function closest( - request: Request, - env: Env, - tenant: string, -): Promise { - const body = parseJson(await readBody(request, MAX_QUERY_BYTES)); - if (!isObject(body) || typeof body.rule !== "string") { - throw refusal(400, "rule must be a string"); - } - if (!RULE_NAME.test(body.rule)) throw refusal(400, "bad rule"); - const found = await findClosest( - env, - tenant, - body.rule, - checkParts(body.parts), - checkSession(body.session), - ); - return json({ closest: found ?? null }); + result, + run: run ?? "unknown", + }, + }); } -async function route( - request: Request, - env: Env, - ctx: ExecutionContext, -): Promise { +async function route(request: Request, env: Env): Promise { const url = new URL(request.url); - const isRecording = url.pathname.startsWith(RECORDINGS_PATH); - if (!isRecording && url.pathname !== "/v1/closest") { + if (!url.pathname.startsWith(RECORDINGS_PATH)) { return json({ error: "not found" }, 404); } const tenant = await tenantOf(request, env); - if (url.pathname === "/v1/closest") { - if (request.method !== "POST") return json({ error: "use POST" }, 405); - return closest(request, env, tenant); - } - const { key, rule } = keyOf(url); + const key = url.pathname.slice(RECORDINGS_PATH.length); + if (!RECORDING_KEY.test(key)) throw refusal(400, "bad key"); + const run = request.headers.get(RUN_HEADER)?.slice(0, MAX_RUN) ?? null; + const objectKey = `${tenant}/${key}`; + switch (request.method) { - case "GET": - return readRecording(env, ctx, tenant, key); - case "PUT": - return writeRecording(request, env, tenant, key, rule); + case "GET": { + const object = await env.RECORDINGS.get(objectKey); + countUse(tenant, key, run, object ? "replayed" : "missed"); + if (!object) return json({ error: "no recording" }, 404); + return new Response(object.body, { + headers: { + "content-type": "application/json", + "cache-control": "no-store", + }, + }); + } + case "PUT": { + const text = await readBody(request); + checkRecording(text); + const digest = hexOf(await sha256(text)); + const previous = await env.RECORDINGS.head(objectKey); + const changed = previous?.customMetadata?.digest !== digest; + if (changed) { + await env.RECORDINGS.put(objectKey, text, { + httpMetadata: { contentType: "application/json" }, + customMetadata: { digest }, + }); + } + countUse(tenant, key, run, changed ? "written" : "unchanged"); + return json({ changed }); + } default: return json({ error: "use GET or PUT" }, 405); } } -/** - * Delete the recordings that nobody used for `RECORDING_TTL_DAYS`, in - * batches. D1 goes first, so a recording that a replay uses during the - * delete keeps its row. Rows of `parts` go with their recording. - */ -async function expire(env: Env, now: number): Promise { - const cutoff = now - env.RECORDING_TTL_DAYS * DAY_MS; - let deleted = 0; - for (let batch = 0; batch < MAX_EXPIRY_BATCHES; batch += 1) { - const { results } = await env.DB.prepare( - `DELETE FROM recordings - WHERE last_used < ?1 AND (tenant, key) IN ( - SELECT tenant, key FROM recordings WHERE last_used < ?1 LIMIT ?2 - ) - RETURNING tenant, key`, - ) - .bind(cutoff, EXPIRY_BATCH) - .all<{ tenant: string; key: string }>(); - if (results.length === 0) break; - await env.RECORDINGS.delete( - results.map((row) => `${row.tenant}/${row.key}`), - ); - deleted += results.length; - if (results.length < EXPIRY_BATCH) break; - } - return deleted; -} - -export default { - async fetch(request, env, ctx) { +export default Sentry.withSentry((env) => ({ dsn: env.SENTRY_DSN }), { + async fetch(request, env) { try { - return await route(request, env, ctx); + return await route(request, env); } catch (error) { if (isRefusal(error)) { return json({ error: error.message }, error.status); @@ -359,9 +200,4 @@ export default { throw error; } }, - - async scheduled(controller, env) { - const deleted = await expire(env, controller.scheduledTime); - console.log(`Deleted ${deleted} expired recordings`); - }, -} satisfies ExportedHandler; +} satisfies ExportedHandler); diff --git a/worker/migrations/0001_recordings.sql b/worker/migrations/0001_recordings.sql deleted file mode 100644 index 4bbc463..0000000 --- a/worker/migrations/0001_recordings.sql +++ /dev/null @@ -1,28 +0,0 @@ --- One row for each recording. The recording itself is in R2, at --- `/`. `last_used` is in milliseconds since the epoch. The --- scheduled handler deletes rows that nobody used within the TTL. -CREATE TABLE recordings ( - tenant TEXT NOT NULL, - key TEXT NOT NULL, - rule TEXT NOT NULL, - session TEXT, - last_used INTEGER NOT NULL, - PRIMARY KEY (tenant, key) -) WITHOUT ROWID; - -CREATE INDEX recordings_last_used ON recordings (last_used); - --- The hash of each part of each recording, for miss diagnosis. A miss --- looks up its own parts in `parts_lookup`, so it reads only the rows that --- are equal, not every recording of the rule. -CREATE TABLE parts ( - tenant TEXT NOT NULL, - key TEXT NOT NULL, - rule TEXT NOT NULL, - part TEXT NOT NULL, - hash TEXT NOT NULL, - PRIMARY KEY (tenant, key, part), - FOREIGN KEY (tenant, key) REFERENCES recordings (tenant, key) ON DELETE CASCADE -) WITHOUT ROWID; - -CREATE INDEX parts_lookup ON parts (tenant, rule, part, hash); diff --git a/wrangler.jsonc b/wrangler.jsonc index 3e0cce5..4ab6448 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -5,24 +5,15 @@ "name": "roach", "main": "worker/index.ts", "compatibility_date": "2026-10-01", + // The Sentry SDK needs AsyncLocalStorage. + "compatibility_flags": ["nodejs_compat"], "workers_dev": true, "observability": { "enabled": true }, - "vars": { - // Days that a recording stays after its last use. - "RECORDING_TTL_DAYS": 30, - }, // ROACH_TENANTS is a secret: `wrangler secret put ROACH_TENANTS`. + // SENTRY_DSN is an optional secret too. "secrets": { "required": ["ROACH_TENANTS"] }, + // A lifecycle rule on the bucket deletes recordings after 30 days. "r2_buckets": [ { "binding": "RECORDINGS", "bucket_name": "roach-recordings" }, ], - "d1_databases": [ - { - "binding": "DB", - "database_name": "roach", - "migrations_dir": "worker/migrations", - }, - ], - // Delete expired recordings once a day. - "triggers": { "crons": ["17 4 * * *"] }, } From 08af1d9a15625eb5cc19222e3ed5cfce6a046c46 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:20:58 +0000 Subject: [PATCH 04/11] fix(store): Match prune keys with slash separators on Windows Used files list keys with '/', but readdir returns paths with the platform separator. On Windows every recording looked unused and prune deleted all of them. --- src/recordings.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/recordings.ts b/src/recordings.ts index c816252..8c3b22b 100644 --- a/src/recordings.ts +++ b/src/recordings.ts @@ -161,7 +161,10 @@ export async function pruneRecordings( const recordings = (await readdir(directory, { recursive: true })).filter( (file) => file.endsWith(".json"), ); - const unused = recordings.filter((file) => !used.has(file)); + // Used files list keys, which use `/`. On Windows, `readdir` uses `\`. + const unused = recordings.filter( + (file) => !used.has(file.split(path.sep).join("/")), + ); await Promise.all(unused.map((file) => rm(path.join(directory, file)))); return unused.length; } From dd5f5ed637a7925522df012ff185303619eeeeeb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:23:59 +0000 Subject: [PATCH 05/11] ref: Trim the store change to what the R2 Worker needs Move the part comparison back into request-key.ts, since the Worker no longer imports it. RequestParts moves to store.ts so the Worker can still typecheck store.ts without Node types. Drop the atomic file writes, which no reader needed. Fix comments and docs that still described the D1 index and expiry by last use. Co-Authored-By: David Cramer --- .gitignore | 1 - README.md | 1 - src/parts.ts | 54 ---------------------------------------------- src/recorder.ts | 3 +-- src/recordings.ts | 29 +++++++------------------ src/report.ts | 2 +- src/request-key.ts | 49 +++++++++++++++++++++++++++++++++++++---- src/store.ts | 5 +++-- src/types.ts | 4 ++-- 9 files changed, 60 insertions(+), 88 deletions(-) delete mode 100644 src/parts.ts diff --git a/.gitignore b/.gitignore index 8a88d3c..19cdd41 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ agents.lock .agents/.gitignore .wrangler .dev.vars -dist diff --git a/README.md b/README.md index e1f2b94..0881385 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,6 @@ The proxy is in `src/`. The Worker is in `worker/`. Tests are in `tests/`. - `store.ts`: the recording format and the store contract. - `recordings.ts`: the file store and prune. - `remote-store.ts`: the store that calls the Worker. -- `parts.ts`: compares the parts of requests to debug a miss. - `values.ts`: changing values and their placeholders. - `streams.ts`: merges the deltas of a recorded model stream. - `certificates.ts`: the certificate authority for HTTPS. diff --git a/src/parts.ts b/src/parts.ts deleted file mode 100644 index 01e4aa4..0000000 --- a/src/parts.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * The parts of a request, which miss diagnosis compares. - * - * The parts are the method, the URL, each key header, each top-level field - * of a JSON body, and each item of a top-level array, such as - * `messages[3]`. A recording keeps a short hash of each part - * (`request-key.ts`). When a request has no recording, the file store - * finds the recording with the most equal parts and reports the parts that - * differ. This only helps a person debug a miss. It never makes a replay. - */ - -/** A short hash of each part of a request, by part name. */ -export type RequestParts = Record; - -const partOrder = new Intl.Collator("en", { numeric: true }).compare; - -/** How many parts of `parts` are equal in `other`. */ -const countEqualParts = (parts: RequestParts, other: RequestParts) => - Object.keys(parts).filter((name) => other[name] === parts[name]).length; - -/** The parts that are in only one of `a` and `b`, or differ, in order. */ -function differingParts(a: RequestParts, b: RequestParts): string[] { - const names = new Set([...Object.keys(a), ...Object.keys(b)]); - return [...names].filter((name) => a[name] !== b[name]).toSorted(partOrder); -} - -/** - * The candidate with the most equal parts, and the parts that differ from - * it, in a readable order. Returns `undefined` when there is no candidate. - */ -export function closestRequest( - parts: RequestParts, - candidates: Iterable, -): { candidate: T; differs: string[] } | undefined { - let best: T | undefined; - let bestEqual = -1; - for (const candidate of candidates) { - const equal = countEqualParts(parts, candidate.parts); - if (equal > bestEqual) { - bestEqual = equal; - best = candidate; - } - } - return ( - best && { candidate: best, differs: differingParts(parts, best.parts) } - ); -} - -/** Name some parts, such as `messages[3], tools`. */ -export function describeParts(parts: string[]): string { - if (parts.length === 0) return "no part"; - const shown = parts.slice(0, 6).join(", "); - return parts.length > 6 ? `${shown} and ${parts.length - 6} more` : shown; -} diff --git a/src/recorder.ts b/src/recorder.ts index 6e3e33f..756aa24 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -20,10 +20,9 @@ import { mkdir, writeFile } from "node:fs/promises"; import type { IncomingHttpHeaders } from "node:http"; import path from "node:path"; -import { describeParts } from "./parts.ts"; import { createFileStore } from "./recordings.ts"; import { createRemoteStore } from "./remote-store.ts"; -import { keyRequest, type KeyedRequest } from "./request-key.ts"; +import { describeParts, keyRequest, type KeyedRequest } from "./request-key.ts"; import { createSecrets } from "./secrets.ts"; import { recordingKey, diff --git a/src/recordings.ts b/src/recordings.ts index 8c3b22b..1d5341f 100644 --- a/src/recordings.ts +++ b/src/recordings.ts @@ -2,26 +2,16 @@ * The file store, and prune. * * The file store keeps each recording (`store.ts`) in `/`, - * so recordings can be committed. For miss diagnosis, it reads all - * recordings of a rule on the first miss of that rule in a run. That is - * one read of the local disk per rule. The remote store does the same - * search in a database query. + * so recordings can be committed. */ -import { randomBytes } from "node:crypto"; -import { - mkdir, - readdir, - readFile, - rename, - rm, - writeFile, -} from "node:fs/promises"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { closestRequest, type RequestParts } from "./parts.ts"; +import { closestRequest } from "./request-key.ts"; import { formatRecording, type Recording, type RecordingStore, + type RequestParts, } from "./store.ts"; const isMissing = (error: unknown) => @@ -43,8 +33,9 @@ interface IndexEntry { } /** - * The store of the recordings in `directory`. For miss diagnosis, it reads - * all recordings of a rule on its first miss of that rule. + * The store of the recordings in `directory`. To find the closest + * recording of a miss, it reads all recordings of the rule on the first + * miss of that rule, and then keeps them in memory. */ export function createFileStore(directory: string): RecordingStore { const indexes = new Map>>(); @@ -74,11 +65,7 @@ export function createFileStore(directory: string): RecordingStore { } if ((await readText(file)) === content) return false; await mkdir(path.dirname(file), { recursive: true }); - // Write a new file and rename it, so a reader in another run - // never sees half of a recording. - const partial = `${file}.${randomBytes(6).toString("hex")}.tmp`; - await writeFile(partial, content); - await rename(partial, file); + await writeFile(file, content); return true; }), ); diff --git a/src/report.ts b/src/report.ts index 67e34e5..52b8a78 100644 --- a/src/report.ts +++ b/src/report.ts @@ -1,7 +1,7 @@ /** * Text reports of a Roach run, for logs and CI summaries. */ -import { describeParts } from "./parts.ts"; +import { describeParts } from "./request-key.ts"; import type { RecordingMiss, RecordingStats } from "./types.ts"; /** Describe the totals of a run in one line. */ diff --git a/src/request-key.ts b/src/request-key.ts index 4f14831..27e0587 100644 --- a/src/request-key.ts +++ b/src/request-key.ts @@ -1,18 +1,23 @@ /** - * The key of a recorded request, and the hashes of its parts. + * The key of a recorded request, and the parts that miss diagnosis + * compares. * * The key is the hash of the rule, the method, the URL, the key headers, * and the body. A JSON body has sorted object keys, so key order does not * matter. Each changing value of the rule is `<>` in the key * (`values.ts`). * - * A recording also keeps a short hash of each part of its request - * (`parts.ts`). + * A recording also keeps a short hash of each part of its request: the + * method, the URL, each key header, each top-level field of a JSON body, + * and each item of a top-level array, such as `messages[3]`. When a request + * has no recording, the file store finds the recording with the most equal + * parts and reports the parts that differ. This only helps a person debug a + * miss. It never makes a replay. */ import { createHash } from "node:crypto"; import type { IncomingHttpHeaders } from "node:http"; -import type { RequestParts } from "./parts.ts"; import { THINKING_BLOCK_TYPES } from "./streams.ts"; +import type { RequestParts } from "./store.ts"; import type { RecordingRule } from "./types.ts"; import { extractValues, @@ -194,3 +199,39 @@ export function keyRequest( }, }; } + +const partOrder = new Intl.Collator("en", { numeric: true }).compare; + +/** + * The candidate with the most equal parts, and the parts that differ from + * it, in a readable order. Returns `undefined` when there is no candidate. + */ +export function closestRequest( + parts: RequestParts, + candidates: Iterable, +): { candidate: T; differs: string[] } | undefined { + let best: T | undefined; + let bestEqual = -1; + for (const candidate of candidates) { + const equal = Object.keys(parts).filter( + (name) => candidate.parts[name] === parts[name], + ).length; + if (equal > bestEqual) { + bestEqual = equal; + best = candidate; + } + } + if (!best) return undefined; + const names = new Set([...Object.keys(parts), ...Object.keys(best.parts)]); + const differs = [...names] + .filter((name) => parts[name] !== best.parts[name]) + .toSorted(partOrder); + return { candidate: best, differs }; +} + +/** Name some parts, such as `messages[3], tools`. */ +export function describeParts(parts: string[]): string { + if (parts.length === 0) return "no part"; + const shown = parts.slice(0, 6).join(", "); + return parts.length > 6 ? `${shown} and ${parts.length - 6} more` : shown; +} diff --git a/src/store.ts b/src/store.ts index f2c0918..3f3a0a8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,7 +2,7 @@ * Recordings, and the contract of a store that keeps them. * * A recording keeps the response of one request, the session (test) that - * recorded it, and the parts of the request (`parts.ts`). It does not keep + * recorded it, and the parts of the request (`request-key.ts`). It does not keep * the request body, so prompts and other inputs are not stored. * * A store keeps recordings by key, `/.json`. The file store @@ -11,7 +11,8 @@ * * This file uses no Node built-ins, because the Worker uses it too. */ -import type { RequestParts } from "./parts.ts"; +/** A short hash of each part of a request, by part name. */ +export type RequestParts = Record; /** One recorded response. */ export interface Recording { diff --git a/src/types.ts b/src/types.ts index e5106f6..292e207 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,8 +75,8 @@ export interface RoachConfig { /** * When the proxy stops, it lists here the recordings that sessions used. * A failed session uses all recordings that it recorded before. Give these - * files to the `prune` command. Only for `directory`: the Worker deletes - * recordings that nobody used for some time. + * files to the `prune` command. Only for `directory`: R2 deletes the recordings + * of the Worker 30 days after they were written. */ usedFile?: string; /** From 5b4ae955a9abb0544325632abe852e4664e5884f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:02:16 +0000 Subject: [PATCH 06/11] feat(service): Run Roach as a remote proxy on GCP with GCS recordings Roach is now a hosted man-in-the-middle proxy. Clients point HTTPS_PROXY at it and trust its CA; they install nothing else. Cloudflare Workers cannot accept CONNECT, so the Worker store is gone. The service holds many runs at once. Each proxied request names its run in Proxy-Authorization. Replay runs are public, so fork CI can replay; any mode that writes needs the tenant token. Recordings live in a GCS bucket per tenant prefix and expire 30 days after they are written. Each read and write sends a roach.recording count to Sentry with the tenant, key, run, and result. deploy/gcp is the production setup: a GCS bucket with the TTL rule, the CA and tenant tokens, the service config in Secret Manager, one COS VM, and an SSL proxy load balancer with a managed certificate. The Image workflow builds the container and pushes it to ghcr on main, and runs terraform validate. A request body over 64 MiB now gets a 413 instead of a reset socket. Co-Authored-By: David Cramer --- .dev.vars.example | 5 - .dockerignore | 5 + .github/workflows/image.yml | 58 ++ .gitignore | 2 - AGENTS.md | 20 +- Dockerfile | 13 + README.md | 302 ++++++----- deploy/gcp/.gitignore | 4 + deploy/gcp/.terraform.lock.hcl | 64 +++ deploy/gcp/cloud-init.yaml | 31 ++ deploy/gcp/main.tf | 247 +++++++++ deploy/gcp/outputs.tf | 20 + deploy/gcp/roach.tfvars.example | 23 + deploy/gcp/variables.tf | 62 +++ deploy/gcp/versions.tf | 29 + knip.json | 5 +- package.json | 13 +- pnpm-lock.yaml | 906 +++++++++++--------------------- pnpm-workspace.yaml | 1 - src/certificates.ts | 99 ++-- src/cli.ts | 44 +- src/client.ts | 108 +++- src/gcs.ts | 145 +++++ src/recorder.ts | 40 +- src/remote-store.ts | 87 --- src/server.ts | 394 ++++++++++---- src/service.ts | 446 ++++++++++++++++ src/store.ts | 10 +- src/types.ts | 31 +- tests/ci-job.ts | 49 ++ tests/deployed.test.ts | 335 ++++++++++++ tests/service.test.ts | 240 +++++++++ tests/worker.test.ts | 250 --------- worker/index.ts | 203 ------- worker/tsconfig.json | 8 - wrangler.jsonc | 19 - 36 files changed, 2777 insertions(+), 1541 deletions(-) delete mode 100644 .dev.vars.example create mode 100644 .dockerignore create mode 100644 .github/workflows/image.yml create mode 100644 Dockerfile create mode 100644 deploy/gcp/.gitignore create mode 100644 deploy/gcp/.terraform.lock.hcl create mode 100644 deploy/gcp/cloud-init.yaml create mode 100644 deploy/gcp/main.tf create mode 100644 deploy/gcp/outputs.tf create mode 100644 deploy/gcp/roach.tfvars.example create mode 100644 deploy/gcp/variables.tf create mode 100644 deploy/gcp/versions.tf create mode 100644 src/gcs.ts delete mode 100644 src/remote-store.ts create mode 100644 src/service.ts create mode 100644 tests/ci-job.ts create mode 100644 tests/deployed.test.ts create mode 100644 tests/service.test.ts delete mode 100644 tests/worker.test.ts delete mode 100644 worker/index.ts delete mode 100644 worker/tsconfig.json delete mode 100644 wrangler.jsonc diff --git a/.dev.vars.example b/.dev.vars.example deleted file mode 100644 index 241d1b8..0000000 --- a/.dev.vars.example +++ /dev/null @@ -1,5 +0,0 @@ -# Copy to .dev.vars for `wrangler dev`. The value is the SHA-256 hex of each -# tenant token. This one is the hash of "local-token". -ROACH_TENANTS={"local":"c7ec7c548f5992a239dc52fea748b808b408fe083c4625e9aa6ffe717343f9c1"} -# Optional: send the roach.recording metrics to Sentry. -# SENTRY_DSN=https://@.ingest.sentry.io/ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..07c788c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!package.json +!pnpm-lock.yaml +!pnpm-workspace.yaml +!src diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..5fd5439 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,58 @@ +name: Image + +# Build the service image on each pull request. On main, also push it to +# ghcr.io/getsentry/roach, where deploy/gcp pulls it from. +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + image: + name: image + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - if: github.ref == 'refs/heads/main' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: ${{ github.ref == 'refs/heads/main' }} + tags: | + ghcr.io/getsentry/roach:main + ghcr.io/getsentry/roach:${{ github.sha }} + + terraform: + name: terraform + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: deploy/gcp + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + - run: terraform fmt -check + - run: terraform init -backend=false + - run: terraform validate diff --git a/.gitignore b/.gitignore index 19cdd41..55390c4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,3 @@ coverage # Auto-generated by dotagents — do not commit these files. agents.lock .agents/.gitignore -.wrangler -.dev.vars diff --git a/AGENTS.md b/AGENTS.md index 88ba0f9..d7d7f46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,24 +13,24 @@ | Test case | `pnpm exec vitest run tests/roach.test.ts -t "…"` | | Lint file | `pnpm exec oxlint --deny-warnings src/server.ts` | | Format | `pnpm format` | -| Typecheck | `pnpm typecheck` (proxy and Worker) | -| Worker tests | `pnpm exec vitest run tests/worker.test.ts` | +| Typecheck | `pnpm typecheck` | +| Deployed shape | `pnpm exec vitest run tests/deployed.test.ts` | | Unused code/deps | `pnpm knip` | | Everything (CI) | `pnpm check` | ## External References -| Need | File | -| -------------------------------------- | ----------------------- | -| Usage, config, recordings, control API | `README.md` | -| Worker setup, routes, and limits | "Worker" in `README.md` | -| Public types | `src/types.ts` | -| Map of source files | "Files" in `README.md` | +| Need | File | +| -------------------------------------- | ------------------------------------- | +| Usage, config, recordings, control API | `README.md` | +| Service access, limits, and deploy | "Service" and "Deploy" in `README.md` | +| Public types | `src/types.ts` | +| Map of source files | "Files" in `README.md` | ## Key Conventions -- The proxy (`src/`) has no runtime dependencies. Use Node built-ins and the `openssl` command only. The only runtime dependency is `@sentry/cloudflare`, for `worker/`. -- `worker/` runs on Cloudflare Workers, with `worker/tsconfig.json`. The `src/` files that it imports (`store.ts`) must not use Node built-ins. +- Use Node built-ins and the `openssl` command. The only runtime dependency is `@sentry/node`, and only `src/service.ts` imports it. The local proxy and the client must not load it. +- `deploy/gcp/` is the production setup (Terraform). Change it in the same change when the service config (`RoachServiceConfig`) changes. CI runs `terraform validate` on it. - Node runs `src/` as TypeScript with type stripping. Use only erasable syntax, and import local files with the `.ts` extension. - Use functions and plain objects, not classes. - Start each source file with a comment that says what the file owns. Give each export a short JSDoc. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7019e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# The image of the Roach service. deploy/gcp runs it with the config file +# at /config/roach.json. See "Deploy" in README.md. +FROM node:24-alpine +# Roach signs a certificate for each intercepted host with openssl. +RUN apk add --no-cache openssl && corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --prod --frozen-lockfile --ignore-scripts +COPY src ./src +USER node +EXPOSE 8080 +ENTRYPOINT ["node", "src/cli.ts"] +CMD ["service", "/config/roach.json"] diff --git a/README.md b/README.md index 0881385..ca58a16 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,97 @@ # Roach -Roach is a recording HTTP proxy for tests. It records chosen requests and +Roach is a recording HTTPS proxy for tests. It records chosen requests and replays them, so a second run of a test suite makes no live calls. The proxy owns every read and write of the recordings. A test runner only gives it rules and tells it when each test starts and ends. -The recordings are in one of two stores: +Roach runs in one of two ways: -- **Files** (`directory`): JSON files that you commit to git. -- **A Roach Worker** (`store`): a Cloudflare Worker with R2. Many - repositories and CI runs can use it. R2 deletes each recording 30 days - after it was written. See [Worker](#worker). - -The proxy always runs in the process tree of the test run. Only the -recordings move to the Worker. +- **A shared service** (`service.ts`): one deployed proxy for many + projects and CI runs. A client points `HTTPS_PROXY` at it and trusts its + certificate authority. It installs nothing else. The recordings are in a + GCS bucket, which deletes each one 30 days after it was written. See + [Service](#service) and [Deploy](#deploy). +- **A local proxy** (`server.ts`): a process in the process tree of the + test run, with the recordings in JSON files that you commit to git. Roach started in [`getsentry/junior`](https://github.com/getsentry/junior/tree/main/packages/junior-evals/src/roach). -The proxy uses only Node built-ins and the `openssl` command. The Worker -uses the Sentry SDK (`@sentry/cloudflare`). +It uses Node built-ins and the `openssl` command. Only the service uses the +Sentry SDK (`@sentry/node`). -## Use +## Use the service ```ts -import { spawnRoach, connectRoach } from "@sentry/roach/client"; +import { startRemoteRun, connectRoach } from "@sentry/roach/client"; import { describeRecordingStats } from "@sentry/roach/report"; import { VALUE_PATTERNS } from "@sentry/roach/values"; -// Once per run. +// Once per CI job. Without `token`, only `replay` mode is allowed. +const run = await startRemoteRun( + { url: "https://roach.example.com", token: process.env.ROACH_TOKEN }, + { + tenant: "junior", + mode: "auto", + name: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`, + rules: [ + { + name: "model", + match: { method: "POST", url: "https://ai-gateway.vercel.sh/" }, + keyHeaders: ["ai-model-id"], + values: { uuid: VALUE_PATTERNS.uuid, time: VALUE_PATTERNS.isoTime }, + }, + ], + }, +); +spawn("vitest", { env: { ...process.env, ...run.env } }); + +// Once per test, in a worker that has the `url`, `controlUrl`, and `token` +// of the run. +const session = await connectRoach({ url, controlUrl, token }).startSession( + testName, +); +const { missed } = await session.end(passed); +// In `replay` mode, fail the test when `missed` is not 0. + +// At the end of the job. +console.log(describeRecordingStats(await run.close())); +``` + +A client that does not use `client.ts` can do the same with plain HTTP. See +[Control API](#control-api). + +## Use a local proxy + +```ts +import { spawnRoach } from "@sentry/roach/client"; + const proxy = await spawnRoach({ directory: "recordings", mode: "auto", allow: ["https://ai-gateway.vercel.sh"], - rules: [ - { - name: "model", - match: { method: "POST", url: "https://ai-gateway.vercel.sh/" }, - keyHeaders: ["ai-model-id"], - values: { uuid: VALUE_PATTERNS.uuid, time: VALUE_PATTERNS.isoTime }, - }, - ], + rules: [/* as above */], }); spawn("vitest", { env: { ...process.env, ...proxy.env } }); - -// Once per test, in a worker that has the `url` and `token` of the proxy. -const session = await connectRoach({ url, token }).startSession(testName); -const { missed } = await session.end(passed); -// In `replay` mode, fail the test when `missed` is not 0. - -// At the end of the run. +// Sessions as above, with connectRoach({ url: proxy.url, token: proxy.token }). console.log(describeRecordingStats(await proxy.stats())); await proxy.close(); ``` -- `proxy.env` has `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, - `NODE_USE_ENV_PROXY`, and `NODE_EXTRA_CA_CERTS`. Node 24 reads the last two - only at startup. A process that is already running must set its own - agents. +- `env` has `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, + and `NODE_EXTRA_CA_CERTS`. Node 24 reads the last two only at startup. A + process that is already running must set its own agents. - `spawnRoach(config, { launcher })` runs the proxy with a command prefix, such as `sudo`. Use it when the caller cannot reach the network, but the proxy must. ## Config -`src/types.ts` has the full types. +A local proxy takes `RoachConfig` (`src/types.ts`). A run on the service +takes `RunConfig` (`src/service.ts`): `tenant`, `mode`, `rules`, `allow`, +and `name`. The fields below mean the same in both. - `directory`: where the recordings are. Each rule has a subdirectory. -- `store`: `{ url, token, run }` of a Roach Worker, in place of - `directory`. Set one of the two. The proxy calls the Worker directly, and - never writes the token to a recording. `run` is optional. It names the CI - run in the Sentry metrics of the Worker, such as - `${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`. - `mode`: `auto` replays recordings and records misses. `replay` replays recordings and fails a miss with HTTP 412, so nothing goes live. `record` sends every request live and records it again. `off` records and replays @@ -169,101 +190,127 @@ With the file store, the proxy also finds the recording with the most equal parts, from the same session if it can, and logs the parts that differ. This is only a hint to debug a miss. It never makes a replay. To find it, the file store reads all recordings of a rule on the first miss of -that rule in a run, and then keeps them in memory. The Worker does not give -this hint, because it keeps no index of the parts. - -## Command line +that rule in a run, and then keeps them in memory. The GCS store does not +give this hint, because it would have to read every recording. + +## Service + +`cli.ts service ` starts the service. The config is +`RoachServiceConfig` in `src/service.ts`. `deploy/gcp/` writes it for you. + +- **Tenants.** A tenant is a project, such as `junior`. Each tenant has a + write token. The config keeps only the SHA-256 of each token. The + recordings of a tenant are under `/` in the bucket. Tenants never + share recordings. +- **Reads are public.** Anyone can start a `replay` run of a tenant, so CI + jobs of forks replay without a secret. Such a run never writes. Every + other mode needs the tenant token. So treat each recording as readable by + anyone who can make its request. Do not record responses that must stay + private. +- **Runs.** Each run has its own id, token, mode, rules, sessions, and + stats. A proxied request names its run in `Proxy-Authorization`, as + `Basic base64(:)`. The proxy URL of the run has them, so + `HTTPS_PROXY` sends them. A request without them gets HTTP 407. +- **Value patterns.** A pattern runs for every run in the process, so a + run can only use the patterns in `VALUE_PATTERNS` and the ones in + `valuePatterns` of the config. Check each one for slow backtracking + before you add it. +- **Sentry.** With `sentryDsn`, each read and write of a recording sends + the metric `roach.recording`. Its attributes are `tenant`, `rule`, `key`, + `run` (the `name` of the run), and `result`: `replayed`, `missed`, or + `written`. Group by `key` and count `run`, and a recording that only one + run uses shows that a value changes on each run. +- **Certificate authority.** `ca` holds the PEM certificate and key that + clients trust. Keep it across restarts. Host certificates live for 7 + days, and the service makes new ones before they expire. -```sh -node --experimental-strip-types src/cli.ts serve < config.json -node --experimental-strip-types src/cli.ts prune ... -``` +### Limits -`serve` prints the address of the proxy as one JSON line. `client.ts` runs -it. `prune` deletes the recordings that no used file lists. Give it the used -files of every run that shares the directory, or it deletes recordings that -another run needs. +- Runs live in memory in one process. A restart ends the open runs, and + their CI jobs fail. A run that is open for 6 hours ends as failed. +- A request body over 64 MiB gets HTTP 413. +- GCS deletes each recording 30 days after it was written, also when runs + still replay it. Then `auto` mode records it again, and `replay` mode + fails the request with HTTP 412. +- A write replaces the recording with the same key. -## Control API +## Deploy -All calls need `Authorization: Bearer `. `client.ts` calls them. - -- `POST /__roach/session` with `{"name": "..."}`: open a session. -- `POST /__roach/session/end` with `{"name": "...", "passed": true}`: - end it. Returns `{"missed": 0}`, the misses that `replay` mode failed. - Returns HTTP 409 when the session is not open. -- `GET /__roach/stats`: the totals of the run. - -## Worker - -`worker/` is a Cloudflare Worker that keeps recordings for many proxies. -`wrangler.jsonc` is its config. - -- **R2** (`RECORDINGS`) keeps each recording at `/`. -- **Tenants**: each tenant has a token. The secret `ROACH_TENANTS` maps - each tenant name to the SHA-256 hex of its token. A tenant sees only its - own recordings. Give each repository its own tenant. Tenants do not - share recordings, because a key does not include credentials: a shared - key would replay one tenant's private response to another. -- **Expiry**: an R2 lifecycle rule deletes each recording 30 days after it - was written. A replay does not extend this time, and a write of the same - recording does not either. So a recording that replays each day still - expires, and its next request goes live in `auto` mode. In `replay` mode, - that request fails with HTTP 412. R2 can take up to a day more to delete - an expired recording. -- **Sentry**: with the secret `SENTRY_DSN`, each read and write sends the - metric `roach.recording`. Its attributes are `tenant`, `rule`, `key`, - `run`, and `result`: `replayed`, `missed`, `written`, or `unchanged`. - Group by `key` and `run` to find recordings that only one run uses. Those - never replay, which often means a value that changes on each run. The - `key` and `run` attributes have a different value for each recording and - run. +`deploy/gcp/` is the production setup, in Terraform. It makes: -### Set up +- a GCS bucket that deletes each recording after `recording_days` (30), +- the certificate authority and one write token for each tenant, +- the service config, in Secret Manager, +- one Container-Optimized OS VM that runs the image + `ghcr.io/getsentry/roach:main`, with a service account that can only use + the bucket and the secret, +- an SSL proxy load balancer with a Google-managed certificate. It ends TLS + and passes the raw TCP stream, with `CONNECT`, to the VM. -```sh -pnpm exec wrangler r2 bucket create roach-recordings -pnpm exec wrangler r2 bucket lifecycle add roach-recordings expire --expire-days 30 -# Each tenant token is a random secret. Keep only its hash in the Worker. -token=$(openssl rand -hex 32); hash=$(printf %s "$token" | sha256sum | cut -d' ' -f1) -printf '{"junior":"%s"}' "$hash" | pnpm exec wrangler secret put ROACH_TENANTS -pnpm exec wrangler secret put SENTRY_DSN # optional -pnpm worker:deploy -``` +The `Image` workflow builds the image on each pull request and pushes it on +`main`. The VM pulls the image at each start. -Then give the proxy `store: { url: "https://roach..workers.dev", -token, run }`. Keep the token in a CI secret. +### Set up -### Routes +1. Make the package `ghcr.io/getsentry/roach` public once, after the first + push to `main`, so the VM can pull it without credentials. +2. Copy `deploy/gcp/roach.tfvars.example` to `roach.tfvars` and fill it in. + `allow` and `value_patterns` must cover the rules of every tenant. +3. Keep the Terraform state in a private bucket. It holds the CA key and the + tenant tokens. See the `backend "gcs"` comment in `versions.tf`. +4. Apply: + + ```sh + cd deploy/gcp + terraform init + terraform apply -var-file=roach.tfvars + ``` + +5. Point an A record of `domain` at the `ip_address` output. The + certificate works some minutes after DNS does. +6. Give each tenant its token as a CI secret, such as `ROACH_TOKEN`: + `terraform output -json tenant_tokens`. +7. Check it: `curl https:///__roach/ca.pem` returns the CA + certificate. + +To deploy a new image, restart the VM: +`gcloud compute instances reset roach --zone `. To add a tenant, +add it to `tenants` and apply again. Terraform writes a new config version, +and the VM reads it at its next start. -All routes need `Authorization: Bearer `. `src/remote-store.ts` -calls them. The optional header `X-Roach-Run` names the run in the Sentry -metrics. +## Command line -- `GET /v1/recordings/`: the recording, or HTTP 404. -- `PUT /v1/recordings/` with the recording: write it. Returns - `{"changed": true}` when it was new or changed. A body over 10 MiB gets - HTTP 413. +```sh +node src/cli.ts service +node src/cli.ts serve < config.json +node src/cli.ts prune ... +``` -A bad token gets HTTP 401. A bad key or body gets HTTP 400. +`service` starts the shared service. `serve` starts a local proxy and prints +its address as one JSON line. `client.ts` runs it. `prune` deletes the +recordings that no used file lists. Give it the used files of every run +that shares the directory, or it deletes recordings that another run +needs. -### Limits +## Control API -- A tenant can write as many recordings as it wants. There is no quota. -- To add or remove a tenant, change `ROACH_TENANTS`. -- Without `SENTRY_DSN`, the Worker sends nothing to Sentry. +The calls of a run are under `/__roach/runs/` on the service, and under +`/__roach` on a local proxy. They need `Authorization: Bearer `, with +the run token or the token of the local proxy. `client.ts` calls them. -### Run it locally +- `POST /session` with `{"name": "..."}`: open a session. +- `POST /session/end` with `{"name": "...", "passed": true}`: end it. + Returns `{"missed": 0}`, the misses that `replay` mode failed. Returns + HTTP 409 when the session is not open. +- `GET /stats`: the totals of the run. -```sh -cp .dev.vars.example .dev.vars # the tenant "local" with token "local-token" -pnpm worker:dev -``` +The service also has: -`tests/worker.test.ts` runs the Worker from `wrangler.jsonc` with local R2 -(`createTestHarness` of Wrangler) and a local Sentry server. It tests -record and replay through a real proxy, misses, tenants, limits, and the -Sentry metrics. +- `POST /__roach/runs` with a `RunConfig`: start a run. Send + `Authorization: Bearer ` for any mode but `replay`. Returns + the `id`, `token`, proxy `url`, `controlUrl`, and `caCert` of the run. +- `DELETE /__roach/runs/`: end the run. Returns its stats. +- `GET /__roach/ca.pem`: the CA certificate. No token. ## Development @@ -274,23 +321,32 @@ pnpm check # format, lint, types, unused code, and tests, as CI runs them `AGENTS.md` has the conventions and the commands for one file. +- `tests/roach.test.ts` tests a local proxy. +- `tests/service.test.ts` tests the service in the test process. +- `tests/deployed.test.ts` runs the service as it runs in production: from + the command line, behind a TLS server that does what the load balancer + does, with a local GCS server, metadata server, and Sentry server. Each + CI job (`tests/ci-job.ts`) sets only the proxy variables of its run. + ## Files -The proxy is in `src/`. The Worker is in `worker/`. Tests are in `tests/`. +The proxy is in `src/`. Tests are in `tests/`. The production setup is in +`deploy/gcp/` and `Dockerfile`. -- `types.ts`: the configuration and the results. +- `types.ts`: the configuration and the results of a local proxy. - `server.ts`: sockets, HTTPS interception, the allow list, and the control API. +- `service.ts`: the shared service: tenants, runs, access, and metrics. - `recorder.ts`: modes, sessions, replay, and misses. - `request-key.ts`: the key and the part hashes of a request. - `store.ts`: the recording format and the store contract. - `recordings.ts`: the file store and prune. -- `remote-store.ts`: the store that calls the Worker. +- `gcs.ts`: the GCS store. - `values.ts`: changing values and their placeholders. - `streams.ts`: merges the deltas of a recorded model stream. +- `secrets.ts`: redaction of credentials. - `certificates.ts`: the certificate authority for HTTPS. -- `client.ts`: starts the proxy in its own process and calls its control +- `client.ts`: starts a remote run or a local proxy, and calls the control API. - `report.ts`: text reports of a run. - `cli.ts`: the command line. -- `worker/index.ts`: the Worker. diff --git a/deploy/gcp/.gitignore b/deploy/gcp/.gitignore new file mode 100644 index 0000000..f8d2ff9 --- /dev/null +++ b/deploy/gcp/.gitignore @@ -0,0 +1,4 @@ +.terraform/ +*.tfstate +*.tfstate.* +roach.tfvars diff --git a/deploy/gcp/.terraform.lock.hcl b/deploy/gcp/.terraform.lock.hcl new file mode 100644 index 0000000..4df6e15 --- /dev/null +++ b/deploy/gcp/.terraform.lock.hcl @@ -0,0 +1,64 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "8.6.0" + constraints = "~> 8.6" + hashes = [ + "h1:0R4NzRqWuYpKsLsrojiG9+AubWYZd2rZig/a5ZH5EBg=", + "zh:02daa55a1eb95f6d15a525eddc08627955aebd3deb150d3ee0e1e4ade9463507", + "zh:24364014f79c57573c193df1578816b3a49c43600c3579dbecaa6e91ec3f308a", + "zh:35865bc61b746f53bc0945b07cc37bba6b9121a82d95bee26faa6dcf8a67d10a", + "zh:47a39b4580bfb3a99958153fbffedf704d35389ed74eeabb5d886474d127ef97", + "zh:4a737ee52f32f0178f48af97f314ff7332b734b86ff813b05e8c4abe4b5f12cd", + "zh:518195549682db69daf4d683be5e91987669cb0662d04b53fed5c86c43001374", + "zh:6d5b2f905fac8d0dfdf590014f0d981e5505ceae01ba9d34f31cc0333ab1daf5", + "zh:78be62e1d00c76d2dff5a104cb191c26b297dc98249f81e2ff50f7c0e8182ec6", + "zh:9131cc8d6cc5bd2efc6025da2ccd6f23830d70331443bbe06ee2c8683d434494", + "zh:9c9e0c5562af727dfc79c3eed4449784cb10240966752a178821d82194c06f45", + "zh:e18ee10080428e66bd60c22b78277e8d470dfd9a9d4605426aef4a23d06bd912", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.1" + constraints = "~> 3.9" + hashes = [ + "h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.4.1" + constraints = "~> 4.4" + hashes = [ + "h1:1Zk4TqZwWmUZHfGTtM/vw8EKoWecYboMRqwhNaoTopc=", + "zh:0855f9a980426a0af2c4415fb23cccbbb88c2d1dd40d83da6f45622c075b398b", + "zh:189227fdae28e547b1d988f5d83c4280fce02e3152d8b62aa810047e2f9f7e59", + "zh:2557db4f493aa90b3683905d9eeb103e55a3facc4ccda763d6de6025116ae121", + "zh:2cfd8d6a543ac61c6e7c0461839ab89bb8da1ba6c8090ab946b1cf47f100d7fa", + "zh:5c04cf2a683ea60a0072c59bbcf415f232c382c891b3aaeb9bbbb28213e0d878", + "zh:6681e35d50328bc11a49bb61c002121353f6a844cc6d63a0c3db57745ec38a56", + "zh:693b810e438bb1dfbc13a42629597284ac1eb08eb30f46f18738d7a9bb768d34", + "zh:77c4425f75346b23817837f3947b6ab7e765308abbf573fec5d2cafd4b82f142", + "zh:b3fd64ec1cecea98c88d2332dbeac40498ce2506e3235bdbd1afdfbaa47bb758", + "zh:c06224b5797b129a84e2514be43f2b140622928fe9c92b55d7941be3296ea289", + "zh:debc830ec123f27944c6170d76cb4ea3542845896960bb9fc530702774df328e", + "zh:f300fcc909014550fd74b2b751665dcc049f874e7a475903f65671baf2c2d86d", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/deploy/gcp/cloud-init.yaml b/deploy/gcp/cloud-init.yaml new file mode 100644 index 0000000..2440f37 --- /dev/null +++ b/deploy/gcp/cloud-init.yaml @@ -0,0 +1,31 @@ +#cloud-config +# Container-Optimized OS runs this at each boot. The roach unit reads the +# config secret into memory (/run is tmpfs), pulls the image, and runs it. +write_files: + - path: /etc/systemd/system/roach.service + permissions: "0644" + owner: root + content: | + [Unit] + Description=Roach + Wants=network-online.target + After=network-online.target docker.service + + [Service] + Environment=HOME=/run/roach-home + ExecStartPre=/bin/mkdir -p /run/roach /run/roach-home + ExecStartPre=/bin/sh -c 'iptables -w -C INPUT -p tcp --dport ${port} -j ACCEPT || iptables -w -A INPUT -p tcp --dport ${port} -j ACCEPT' + ExecStartPre=/bin/sh -c 'umask 077 && docker run --rm --network host gcr.io/google.com/cloudsdktool/google-cloud-cli:alpine gcloud secrets versions access latest --secret=${secret} --project=${project} > /run/roach/roach.json && chown 1000 /run/roach/roach.json' + ExecStartPre=-/usr/bin/docker rm -f roach + ExecStartPre=/usr/bin/docker pull ${image} + ExecStart=/usr/bin/docker run --rm --name roach --network host -v /run/roach:/config:ro ${image} + ExecStop=/usr/bin/docker stop roach + Restart=always + RestartSec=5 + + [Install] + WantedBy=multi-user.target + +runcmd: + - systemctl daemon-reload + - systemctl start roach.service diff --git a/deploy/gcp/main.tf b/deploy/gcp/main.tf new file mode 100644 index 0000000..7cd97b3 --- /dev/null +++ b/deploy/gcp/main.tf @@ -0,0 +1,247 @@ +# Roach on GCP: one VM behind a TLS load balancer, with the recordings in a +# GCS bucket. See "Deploy" in README.md. +# +# client --TLS--> load balancer (Google-managed certificate) +# --TCP--> VM: Roach container on port 8080 --> upstream APIs +# --> GCS bucket + +locals { + port = 8080 + # Load balancer proxies and health checks come from these ranges. + google_ranges = ["130.211.0.0/22", "35.191.0.0/16"] + # Identity-Aware Proxy, for `gcloud compute ssh --tunnel-through-iap`. + iap_range = "35.235.240.0/20" +} + +resource "google_project_service" "apis" { + for_each = toset(["compute.googleapis.com", "secretmanager.googleapis.com", "storage.googleapis.com"]) + service = each.value + disable_on_destroy = false +} + +# Recordings. Each one is deleted `recording_days` after it was written. +resource "google_storage_bucket" "recordings" { + name = "${var.project}-roach-recordings" + location = var.region + uniform_bucket_level_access = true + public_access_prevention = "enforced" + # Recordings are a cache. Delete them for good. + soft_delete_policy { + retention_duration_seconds = 0 + } + lifecycle_rule { + condition { + age = var.recording_days + } + action { + type = "Delete" + } + } + depends_on = [google_project_service.apis] +} + +# The certificate authority that signs intercepted hosts. Clients trust its +# certificate (the ca_cert output), so it must not change on a restart. +resource "tls_private_key" "ca" { + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_self_signed_cert" "ca" { + private_key_pem = tls_private_key.ca.private_key_pem + is_ca_certificate = true + validity_period_hours = 24 * 365 * 10 + allowed_uses = ["cert_signing", "crl_signing"] + subject { + common_name = "Roach CA" + organization = "Roach" + } +} + +# One write token for each tenant. A run without a token can only replay. +resource "random_password" "tenant" { + for_each = var.tenants + length = 48 + special = false +} + +# The service config (`RoachServiceConfig` in src/service.ts). The VM reads +# it at each start. +resource "google_secret_manager_secret" "config" { + secret_id = "roach-config" + replication { + auto {} + } + depends_on = [google_project_service.apis] +} + +resource "google_secret_manager_secret_version" "config" { + secret = google_secret_manager_secret.config.id + secret_data = jsonencode(merge( + { + host = "0.0.0.0" + port = local.port + publicUrl = "https://${var.domain}" + bucket = google_storage_bucket.recordings.name + allow = var.allow + tenants = { for name, token in random_password.tenant : name => sha256(token.result) } + valuePatterns = var.value_patterns + ca = { + cert = tls_self_signed_cert.ca.cert_pem + key = tls_private_key.ca.private_key_pem + } + }, + var.sentry_dsn == "" ? {} : { sentryDsn = var.sentry_dsn }, + )) +} + +resource "google_service_account" "roach" { + account_id = "roach-proxy" + display_name = "Roach" +} + +resource "google_storage_bucket_iam_member" "roach" { + bucket = google_storage_bucket.recordings.name + role = "roles/storage.objectUser" + member = google_service_account.roach.member +} + +resource "google_secret_manager_secret_iam_member" "roach" { + secret_id = google_secret_manager_secret.config.id + role = "roles/secretmanager.secretAccessor" + member = google_service_account.roach.member +} + +resource "google_compute_network" "roach" { + name = "roach" + auto_create_subnetworks = false + depends_on = [google_project_service.apis] +} + +resource "google_compute_subnetwork" "roach" { + name = "roach" + network = google_compute_network.roach.id + region = var.region + ip_cidr_range = "10.10.0.0/24" +} + +resource "google_compute_firewall" "proxy" { + name = "roach-proxy" + network = google_compute_network.roach.id + source_ranges = local.google_ranges + target_tags = ["roach"] + allow { + protocol = "tcp" + ports = [tostring(local.port)] + } +} + +resource "google_compute_firewall" "ssh" { + name = "roach-ssh" + network = google_compute_network.roach.id + source_ranges = [local.iap_range] + target_tags = ["roach"] + allow { + protocol = "tcp" + ports = ["22"] + } +} + +resource "google_compute_instance" "roach" { + name = "roach" + machine_type = var.machine_type + zone = var.zone + tags = ["roach"] + boot_disk { + initialize_params { + image = "cos-cloud/cos-stable" + size = 20 + } + } + network_interface { + subnetwork = google_compute_subnetwork.roach.id + # An external IP for calls to upstream APIs. The firewall only lets the + # load balancer and IAP in. + access_config {} + } + service_account { + email = google_service_account.roach.email + scopes = ["cloud-platform"] + } + shielded_instance_config { + enable_secure_boot = true + } + metadata = { + user-data = templatefile("${path.module}/cloud-init.yaml", { + image = var.image + project = var.project + port = local.port + secret = google_secret_manager_secret.config.secret_id + }) + google-logging-enabled = "true" + block-project-ssh-keys = "true" + enable-oslogin = "TRUE" + } + allow_stopping_for_update = true + depends_on = [ + google_secret_manager_secret_iam_member.roach, + google_secret_manager_secret_version.config, + google_storage_bucket_iam_member.roach, + ] +} + +resource "google_compute_instance_group" "roach" { + name = "roach" + zone = var.zone + instances = [google_compute_instance.roach.self_link] + named_port { + name = "roach" + port = local.port + } +} + +resource "google_compute_health_check" "roach" { + name = "roach" + tcp_health_check { + port = local.port + } +} + +resource "google_compute_backend_service" "roach" { + name = "roach" + protocol = "TCP" + port_name = "roach" + load_balancing_scheme = "EXTERNAL" + # A tunnel can be idle while a model thinks. + timeout_sec = 3600 + health_checks = [google_compute_health_check.roach.id] + backend { + group = google_compute_instance_group.roach.self_link + } +} + +resource "google_compute_managed_ssl_certificate" "roach" { + name = "roach" + managed { + domains = [var.domain] + } +} + +resource "google_compute_target_ssl_proxy" "roach" { + name = "roach" + backend_service = google_compute_backend_service.roach.id + ssl_certificates = [google_compute_managed_ssl_certificate.roach.id] +} + +resource "google_compute_global_address" "roach" { + name = "roach" +} + +resource "google_compute_global_forwarding_rule" "roach" { + name = "roach" + target = google_compute_target_ssl_proxy.roach.id + ip_address = google_compute_global_address.roach.address + port_range = "443" + ip_protocol = "TCP" + load_balancing_scheme = "EXTERNAL" +} diff --git a/deploy/gcp/outputs.tf b/deploy/gcp/outputs.tf new file mode 100644 index 0000000..8038c04 --- /dev/null +++ b/deploy/gcp/outputs.tf @@ -0,0 +1,20 @@ +output "ip_address" { + description = "Point an A record of the domain at this address. The TLS certificate starts to work some minutes after DNS does." + value = google_compute_global_address.roach.address +} + +output "url" { + description = "The URL of the service. Clients give it to startRemoteRun()." + value = "https://${var.domain}" +} + +output "ca_cert" { + description = "The certificate that clients trust. The service also serves it at /__roach/ca.pem, and each run returns it." + value = tls_self_signed_cert.ca.cert_pem +} + +output "tenant_tokens" { + description = "The write token of each tenant. Keep each one in a CI secret of its project, such as ROACH_TOKEN." + value = { for name, token in random_password.tenant : name => token.result } + sensitive = true +} diff --git a/deploy/gcp/roach.tfvars.example b/deploy/gcp/roach.tfvars.example new file mode 100644 index 0000000..9935360 --- /dev/null +++ b/deploy/gcp/roach.tfvars.example @@ -0,0 +1,23 @@ +# Copy to roach.tfvars, fill in, then: +# terraform init && terraform apply -var-file=roach.tfvars +project = "my-gcp-project" +domain = "roach.example.com" + +tenants = ["junior"] + +# The origins of Junior's evals (packages/junior-evals/src/recording-rules.ts). +allow = [ + "https://ai-gateway.vercel.sh", + "https://vercel.com", + "https://api.vercel.com", + "https://oidc.vercel.com", + "https://docs.slack.dev", +] + +# Junior's own value patterns, in addition to the built-in ones. +value_patterns = [ + "(?= 1.9" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 8.6" + } + random = { + source = "hashicorp/random" + version = "~> 3.9" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.4" + } + } + # The state holds the CA key and the tenant tokens. Keep it in a private + # bucket, for example: + # backend "gcs" { + # bucket = "my-terraform-state" + # prefix = "roach" + # } +} + +provider "google" { + project = var.project + region = var.region + zone = var.zone +} diff --git a/knip.json b/knip.json index 82c5670..5742288 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "project": ["src/**/*.ts", "tests/**/*.ts", "worker/**/*.ts"], - "ignoreBinaries": ["openssl"] + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreBinaries": ["openssl"], + "entry": ["tests/ci-job.ts"] } diff --git a/package.json b/package.json index a23469e..7db7109 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@sentry/roach", "version": "0.0.0", "private": true, - "description": "A recording HTTPS proxy for tests.", + "description": "A recording HTTPS proxy for tests, as a shared service or a local process.", "license": "Apache-2.0", "repository": { "type": "git", @@ -16,6 +16,7 @@ "./client": "./src/client.ts", "./report": "./src/report.ts", "./server": "./src/server.ts", + "./service": "./src/service.ts", "./types": "./src/types.ts", "./values": "./src/values.ts" }, @@ -30,15 +31,12 @@ "skills:install": "dotagents --project install", "test": "vitest run", "test:coverage": "vitest run --coverage", - "typecheck": "tsc -p . && tsc -p worker", - "worker:dev": "wrangler dev", - "worker:deploy": "wrangler deploy" + "typecheck": "tsc -p ." }, "dependencies": { - "@sentry/cloudflare": "11.6.0" + "@sentry/node": "11.6.0" }, "devDependencies": { - "@cloudflare/workers-types": "^5.20261008.1", "@sentry/dotagents": "^3.3.0", "@types/node": "^24.19.1", "@vitest/coverage-v8": "^5.0.3", @@ -50,8 +48,7 @@ "simple-git-hooks": "^2.14.0", "typescript": "^7.0.2", "undici": "^8.11.2", - "vitest": "^5.0.3", - "wrangler": "^4.149.0" + "vitest": "^5.0.3" }, "simple-git-hooks": { "pre-commit": "pnpm exec lint-staged", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fcbf3b..94b61ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,13 +8,10 @@ importers: .: dependencies: - '@sentry/cloudflare': + '@sentry/node': specifier: 11.6.0 - version: 11.6.0(@cloudflare/workers-types@5.20261008.1)(wrangler@4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1)) + version: 11.6.0 devDependencies: - '@cloudflare/workers-types': - specifier: ^5.20261008.1 - version: 5.20261008.1 '@sentry/dotagents': specifier: ^3.3.0 version: 3.3.0 @@ -51,9 +48,6 @@ importers: vitest: specifier: ^5.0.3 version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - wrangler: - specifier: ^4.149.0 - version: 4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1) packages: @@ -86,65 +80,12 @@ packages: resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} engines: {node: '>= 20.12.0'} - '@cloudflare/kv-asset-handler@0.5.0': - resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} - engines: {node: '>=22.0.0'} - - '@cloudflare/unenv-preset@2.16.2': - resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} - peerDependencies: - unenv: 2.0.0-rc.24 - workerd: '>1.20260305.0 <2.0.0-0' - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/workerd-darwin-64@1.20261006.1': - resolution: {integrity: sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20261006.1': - resolution: {integrity: sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-linux-64@1.20261006.1': - resolution: {integrity: sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20261006.1': - resolution: {integrity: sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20261006.1': - resolution: {integrity: sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cloudflare/workers-types@5.20261008.1': - resolution: {integrity: sha512-Ya6zuPTAQPka+Mn5rMI5f8Zl5bihhm+ytqhHiMzMgpLDGrD24A1k/Z/JPSBIqhIC0bx0AclgYyoZoRs6UWVlmw==} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} - '@emnapi/runtime@1.11.3': - resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -304,168 +245,6 @@ packages: cpu: [x64] os: [win32] - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.35.5': - resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.35.5': - resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [darwin] - - '@img/sharp-freebsd-wasm32@0.35.5': - resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} - engines: {node: '>=20.9.0'} - os: [freebsd] - - '@img/sharp-libvips-darwin-arm64@1.3.4': - resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.3.4': - resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.3.4': - resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.3.4': - resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.3.4': - resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.3.4': - resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.3.4': - resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.3.4': - resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.3.4': - resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.3.4': - resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.35.5': - resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.35.5': - resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} - engines: {node: '>=20.9.0'} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.35.5': - resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} - engines: {node: '>=20.9.0'} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.35.5': - resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} - engines: {node: '>=20.9.0'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.35.5': - resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} - engines: {node: '>=20.9.0'} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.35.5': - resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.35.5': - resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.35.5': - resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.35.5': - resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} - engines: {node: '>=20.9.0'} - - '@img/sharp-webcontainers-wasm32@0.35.5': - resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} - engines: {node: '>=20.9.0'} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.35.5': - resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.35.5': - resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} - engines: {node: ^20.9.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.35.5': - resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [win32] - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -476,9 +255,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@napi-rs/wasm-runtime@1.2.5': resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -496,42 +272,84 @@ packages: cpu: [arm] os: [android] + '@oxc-parser/binding-android-arm-eabi@0.152.0': + resolution: {integrity: sha512-393VtirINkae4XHBTkq/yQxhOwEr4V+r0et37P2fp+sYZSdYgZTRDPo5Um8vBh0QjMZ+RmaMy4PP0qwr4cTMwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm64@0.150.0': resolution: {integrity: sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.152.0': + resolution: {integrity: sha512-iYFcExATJNZ0rZj0o02THbD1Nc9jAW0nVgIQPee6ejbSASpbZXlIMw8jxtpzaG14JQTWwGSZ6U5dEtRJXE6V3Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-darwin-arm64@0.150.0': resolution: {integrity: sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.152.0': + resolution: {integrity: sha512-fnU+DfUzi5/rFy0LzmipYRz06PAUUkOS8kCJQ3Eg6oqlunC1XZXoffFqxmdL51RmcbYtUG3P8DVG1U5fckoa2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.150.0': resolution: {integrity: sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.152.0': + resolution: {integrity: sha512-Y3U2okryWTs7hDld6UhjJwLNf8/do+x4g4C+1U5SiGwrTLwbO83WJYJA8+l9iFSilWBgnfpd/+U76tfeNxUHzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-freebsd-x64@0.150.0': resolution: {integrity: sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.152.0': + resolution: {integrity: sha512-XkhkRrrPffEw+ZLv/d/5pEni0buxinm/E/N6fLbJCunD1DBbeCTtVP23ESV+4CWCWdSz397asks1ViGslpmWiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': resolution: {integrity: sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.152.0': + resolution: {integrity: sha512-MLzsRRbdfmdzo6JEhbC1yKU9ItfvM8tEf18HM32jCiNYZEz6J3bRBRl3cTkKR0vBdrk9ZVlcARXtwHccs+epDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': resolution: {integrity: sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.152.0': + resolution: {integrity: sha512-HHerTEJz7Iy10Ji6BGsk4gqG6PU0UzFuBu91jIEOX89FnrI5ECWSEr7BLpkYQhp7U0QFtMnBhNfQKI0hYZK78w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': resolution: {integrity: sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -539,6 +357,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.152.0': + resolution: {integrity: sha512-9Bb8GyzZ2uSLzRDZ27usaKokr07mxiBe2dDF5b6bd2janmaJTT1gL51UfP7AU30yFiXrJvcRyFKREAgKwZYs0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-arm64-musl@0.150.0': resolution: {integrity: sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -546,6 +371,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.152.0': + resolution: {integrity: sha512-om7iDMUroI74A/lfRRWY9/Ev3VWrVsrUZ4tyvyh8raBey/sdcfyAD7tYWc+iykAiotnmftyKTpUKFr1QnaBrAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': resolution: {integrity: sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -553,6 +385,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.152.0': + resolution: {integrity: sha512-Lf1OL2lMTqwDMFC6jnRb30B+WYHKV0XT+bPHcFM6xHtNVwxpobiTzUIvKH6a0LWpvfwyru8Yu6zCGHqqFHH+gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': resolution: {integrity: sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -560,6 +399,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.152.0': + resolution: {integrity: sha512-BYWSMQK+YJ9ZILStPcmmiSVq8KEG9Qdq/c4BHPC3rUEq/fEDCVjUIwdj5ZzjgZ9P9is9PKu0tcAXzq1ZXg4urQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': resolution: {integrity: sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -567,6 +413,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.152.0': + resolution: {integrity: sha512-d2LqHNNNqdH185sRK5dPKWZu201JrNu9/CRFuVEuT+ht7n/QaVblEMWT9MALQPrDWYvhG10P+NSKJFquDWZ09w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': resolution: {integrity: sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -574,6 +427,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.152.0': + resolution: {integrity: sha512-8gBeHt5rA/8+pKhHl+zLggfa3L/V/L2at96p7ty6pBE7id6lFO19nFUc15u2PcjC4OYeYK465MKy1fhhbpFIGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.150.0': resolution: {integrity: sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -581,6 +441,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.152.0': + resolution: {integrity: sha512-ltgrSF6L+yPTVsIdaAyqKq3zTqzj/1VlNbxWZuxAe8flR3sWdyNsKvdXukoO8/ucJbLickhmieRBge+o/e8Ugw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-musl@0.150.0': resolution: {integrity: sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -588,33 +455,67 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.152.0': + resolution: {integrity: sha512-LcMp6BvrBnewSfdkLHoSqidhoAq4syKOTBV5TYHWd+2VnGkfD/nPRRVIzTBt/Lkbt7pfOFDGtkiTntFS0p6sQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-openharmony-arm64@0.150.0': resolution: {integrity: sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.152.0': + resolution: {integrity: sha512-jbhoMCslVxzRCRNQ8TSHxjYdSKRvk8O6ciLVDkahJvmyHDzBqba/bMQHM6ULKj/rgHNNbSzlIiakdx8SEfkJzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': resolution: {integrity: sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.152.0': + resolution: {integrity: sha512-JZYVlhv36mr/nertnRB7vchJECOBb1Lz6lltalILsB+o9M/is+H6BEDRLQKdqA4JKs6NjJszo46+iM5sfwj6jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': resolution: {integrity: sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.152.0': + resolution: {integrity: sha512-1X9oganvVsunRH0lH10UU05N+H9atpX6WsOybByUjwnfcd3h/0tYwvuv16XuHglrIUOnxV2TodbLVEwwafd64g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.150.0': resolution: {integrity: sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.152.0': + resolution: {integrity: sha512-sw0M80/dn9rcpu+Rqqph/C+MjvDDce6MlXmFjsNQtPEymha0eJy3ml6+foSUTqFfpDIDyHZZ4eoJ43IoMe8bJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.150.0': resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + '@oxc-project/types@0.153.0': resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} @@ -995,15 +896,6 @@ packages: cpu: [x64] os: [win32] - '@poppinss/colors@4.1.6': - resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} - - '@poppinss/dumper@0.6.5': - resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} - - '@poppinss/exception@1.2.3': - resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@rolldown/binding-android-arm-eabi@1.2.13': resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1103,16 +995,19 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@sentry/cloudflare@11.6.0': - resolution: {integrity: sha512-FOwluoZmgE60JLxQBKUw0smiz3FaVsmtuvslkgLb+9pUbxGsjnLf1RX8F99vPSCB724Q916asImCHTlWRynUSQ==} + '@sentry/bundler-plugins@11.6.0': + resolution: {integrity: sha512-qHKQgOQaayRuwDCWWoAkEMkJe13RZwrpAH5Y5edL3d5XD2Gd7uenA6lPdXFw3qP1slezeSYErV0GAdcVYMo8fg==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} peerDependencies: - '@cloudflare/workers-types': ^4.x || ^5.x - wrangler: ^4.x + '@babel/core': ^7.18.5 + rollup: '>=3.2.0' + webpack: '>=5.1.0' peerDependenciesMeta: - '@cloudflare/workers-types': + '@babel/core': + optional: true + rollup: optional: true - wrangler: + webpack: optional: true '@sentry/conventions@0.26.0': @@ -1132,23 +1027,24 @@ packages: engines: {node: '>=20'} hasBin: true + '@sentry/node@11.6.0': + resolution: {integrity: sha512-5uHdUSACyodmtar+LxhFl4/MdICJ1/38mrp5lrMpDKfWSV/7tu/KRmr5gD2J9Nm47I/xxbghSz+oinJ4EtJatQ==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + '@sentry/opentelemetry@11.6.0': resolution: {integrity: sha512-W6b+/8TVejHSCSOWMGsU2wVQY+d7FH/kI2zIFYXmIcxJCd7A/ULhOXe29UYeh+VX9wObR23LvEcEUmgsFVqf6A==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} peerDependencies: '@opentelemetry/api': ^1.9.0 + '@sentry/server-runtime-injection@11.6.0': + resolution: {integrity: sha512-3plQzwQfvz4HJkXLDfr5VuQQL5vyjar7RKn7UwFne3jc+9OSXRkxkZ6S0Z3RdBgbaFO1GkFwf/mFNX4H8nNcuw==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + '@sentry/server-utils@11.6.0': resolution: {integrity: sha512-pVpGqwxBOXDMSngxTVARRlYyEXEfKA3WU1ENCdgLCeJ4xJ4vNRf4ODGXYPvXBk0zkt2NZZFvray2JHXIeYG+tg==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sindresorhus/is@7.2.0': - resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} - engines: {node: '>=18'} - - '@speed-highlight/core@1.2.24': - resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} - '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -1322,8 +1218,13 @@ packages: ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} - blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} @@ -1333,16 +1234,13 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -1393,6 +1291,14 @@ packages: get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1403,10 +1309,6 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - knip@6.40.0: resolution: {integrity: sha512-5Lp/ANJu3AkW5f+J+2QG14z7xKZGi/ybb5MU1EWjeGdt6MpuIaLpLFiyBi0ZKryTiuTHiJqo/WKJRaWYimvavw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1491,8 +1393,9 @@ packages: engines: {node: '>=22.22.1'} hasBin: true - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -1500,9 +1403,13 @@ packages: magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} - miniflare@5.20261006.1-alpha: - resolution: {integrity: sha512-6ylv8tamkrspDI2R/cAYfv+YWmfGNdXe76Pi9TauEUWsFVXn3LNF9Yk8oduzf1a+TYsSRYTqlOE7+nGGRsMHdw==} - engines: {node: '>=22.0.0'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} @@ -1517,6 +1424,10 @@ packages: resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.152.0: + resolution: {integrity: sha512-ZztsZgaCWmPeKYMdoTT5e0oV/oxD1NeLWNR3kAtr/G707cUPYaaozRlVDHLCGnszHJRszN9mJwmPNpqgwloC0A==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} @@ -1553,11 +1464,9 @@ packages: package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1578,20 +1487,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} + sentry@0.45.0: + resolution: {integrity: sha512-kgmEbLlC22lL7uneeUm9Sjz65rp5cvwYOMTxXbZCmBum4sbVre/lLT3zTV3kwXHJ9/BM8ZS0/GhZe9Cl1t6WyA==} + engines: {node: '>=20.0'} hasBin: true - sharp@0.35.5: - resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} - engines: {node: '>=20.9.0'} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - simple-git-hooks@2.14.0: resolution: {integrity: sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A==} hasBin: true @@ -1618,9 +1518,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} - engines: {node: '>=18'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} tinybench@6.2.1: resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==} @@ -1657,17 +1557,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.29.1: - resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} - engines: {node: '>=20.18.1'} - undici@8.11.2: resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} engines: {node: '>=22.19.0'} - unenv@2.0.0-rc.24: - resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - vite@8.3.4: resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1761,44 +1654,11 @@ packages: engines: {node: '>=20.11'} hasBin: true - workerd@1.20261006.1: - resolution: {integrity: sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==} - engines: {node: '>=16'} - hasBin: true - - wrangler@4.149.0: - resolution: {integrity: sha512-OzK7xmB5r5iLKb3cIT3783g13fe6T7xyeKu9LEdUyl+DKAM2KP/jkjnE0OUhyw+mB/XJM9ycjgF3MWm8PR9amg==} - engines: {node: '>=22.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^5.20261006.1 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - yaml@2.9.1: resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true - youch-core@0.3.3: - resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} - - youch@4.1.0-beta.10: - resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} @@ -1831,35 +1691,6 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@cloudflare/kv-asset-handler@0.5.0': {} - - '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20261006.1 - - '@cloudflare/workerd-darwin-64@1.20261006.1': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20261006.1': - optional: true - - '@cloudflare/workerd-linux-64@1.20261006.1': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20261006.1': - optional: true - - '@cloudflare/workerd-windows-64@1.20261006.1': - optional: true - - '@cloudflare/workers-types@5.20261008.1': {} - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -1871,11 +1702,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.3': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -1959,194 +1785,142 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.4 - optional: true + '@jridgewell/resolve-uri@3.1.2': {} - '@img/sharp-darwin-x64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.4 - optional: true + '@jridgewell/sourcemap-codec@1.6.0': {} - '@img/sharp-freebsd-wasm32@0.35.5': + '@jridgewell/trace-mapping@0.3.31': dependencies: - '@img/sharp-wasm32': 0.35.5 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.3.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.3.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.3.4': - optional: true + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 - '@img/sharp-libvips-linux-arm@1.3.4': + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.4 optional: true - '@img/sharp-libvips-linux-ppc64@1.3.4': - optional: true + '@opentelemetry/api@1.9.1': {} - '@img/sharp-libvips-linux-riscv64@1.3.4': + '@oxc-parser/binding-android-arm-eabi@0.150.0': optional: true - '@img/sharp-libvips-linux-s390x@1.3.4': + '@oxc-parser/binding-android-arm-eabi@0.152.0': optional: true - '@img/sharp-libvips-linux-x64@1.3.4': + '@oxc-parser/binding-android-arm64@0.150.0': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + '@oxc-parser/binding-android-arm64@0.152.0': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.4': + '@oxc-parser/binding-darwin-arm64@0.150.0': optional: true - '@img/sharp-linux-arm64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.4 + '@oxc-parser/binding-darwin-arm64@0.152.0': optional: true - '@img/sharp-linux-arm@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.4 + '@oxc-parser/binding-darwin-x64@0.150.0': optional: true - '@img/sharp-linux-ppc64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@oxc-parser/binding-darwin-x64@0.152.0': optional: true - '@img/sharp-linux-riscv64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@oxc-parser/binding-freebsd-x64@0.150.0': optional: true - '@img/sharp-linux-s390x@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.4 + '@oxc-parser/binding-freebsd-x64@0.152.0': optional: true - '@img/sharp-linux-x64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.4 + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': optional: true - '@img/sharp-linuxmusl-arm64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@oxc-parser/binding-linux-arm-gnueabihf@0.152.0': optional: true - '@img/sharp-linuxmusl-x64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': optional: true - '@img/sharp-wasm32@0.35.5': - dependencies: - '@emnapi/runtime': 1.11.3 + '@oxc-parser/binding-linux-arm-musleabihf@0.152.0': optional: true - '@img/sharp-webcontainers-wasm32@0.35.5': - dependencies: - '@img/sharp-wasm32': 0.35.5 + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': optional: true - '@img/sharp-win32-arm64@0.35.5': + '@oxc-parser/binding-linux-arm64-gnu@0.152.0': optional: true - '@img/sharp-win32-ia32@0.35.5': + '@oxc-parser/binding-linux-arm64-musl@0.150.0': optional: true - '@img/sharp-win32-x64@0.35.5': + '@oxc-parser/binding-linux-arm64-musl@0.152.0': optional: true - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.6.0': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.6.0 - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.6.0 - - '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': - dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@tybys/wasm-util': 0.10.4 + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': optional: true - '@opentelemetry/api@1.9.1': {} - - '@oxc-parser/binding-android-arm-eabi@0.150.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.152.0': optional: true - '@oxc-parser/binding-android-arm64@0.150.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.150.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.152.0': optional: true - '@oxc-parser/binding-darwin-x64@0.150.0': + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.150.0': + '@oxc-parser/binding-linux-riscv64-musl@0.152.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + '@oxc-parser/binding-linux-s390x-gnu@0.152.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + '@oxc-parser/binding-linux-x64-gnu@0.150.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.150.0': + '@oxc-parser/binding-linux-x64-gnu@0.152.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + '@oxc-parser/binding-linux-x64-musl@0.150.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + '@oxc-parser/binding-linux-x64-musl@0.152.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + '@oxc-parser/binding-openharmony-arm64@0.150.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + '@oxc-parser/binding-openharmony-arm64@0.152.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.150.0': + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.150.0': + '@oxc-parser/binding-win32-arm64-msvc@0.152.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.150.0': + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + '@oxc-parser/binding-win32-ia32-msvc@0.152.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + '@oxc-parser/binding-win32-x64-msvc@0.150.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.150.0': + '@oxc-parser/binding-win32-x64-msvc@0.152.0': optional: true '@oxc-project/types@0.150.0': {} + '@oxc-project/types@0.152.0': {} + '@oxc-project/types@0.153.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': @@ -2342,18 +2116,6 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.87.0': optional: true - '@poppinss/colors@4.1.6': - dependencies: - kleur: 4.1.5 - - '@poppinss/dumper@0.6.5': - dependencies: - '@poppinss/colors': 4.1.6 - '@sindresorhus/is': 7.2.0 - supports-color: 10.2.2 - - '@poppinss/exception@1.2.3': {} - '@rolldown/binding-android-arm-eabi@1.2.13': optional: true @@ -2401,17 +2163,15 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@sentry/cloudflare@11.6.0(@cloudflare/workers-types@5.20261008.1)(wrangler@4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1))': + '@sentry/bundler-plugins@11.6.0': dependencies: - '@opentelemetry/api': 1.9.1 - '@sentry/conventions': 0.26.0 '@sentry/core': 11.6.0 - '@sentry/opentelemetry': 11.6.0(@opentelemetry/api@1.9.1) - '@sentry/server-utils': 11.6.0 - magic-string: 0.30.21 - optionalDependencies: - '@cloudflare/workers-types': 5.20261008.1 - wrangler: 4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1) + dotenv: 17.4.2 + glob: 13.0.6 + magic-string: 1.4.3 + oxc-parser: 0.152.0 + sentry: 0.45.0 + supports-color: 8.1.1 '@sentry/conventions@0.26.0': {} @@ -2432,21 +2192,36 @@ snapshots: smol-toml: 1.9.0 zod: 4.6.5 - '@sentry/opentelemetry@11.6.0(@opentelemetry/api@1.9.1)': + '@sentry/node@11.6.0': dependencies: '@opentelemetry/api': 1.9.1 + '@sentry/bundler-plugins': 11.6.0 '@sentry/conventions': 0.26.0 '@sentry/core': 11.6.0 + '@sentry/opentelemetry': 11.6.0(@opentelemetry/api@1.9.1) + '@sentry/server-runtime-injection': 11.6.0 + '@sentry/server-utils': 11.6.0 + transitivePeerDependencies: + - '@babel/core' + - rollup + - webpack - '@sentry/server-utils@11.6.0': + '@sentry/opentelemetry@11.6.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@sentry/conventions': 0.26.0 '@sentry/core': 11.6.0 - '@sindresorhus/is@7.2.0': {} + '@sentry/server-runtime-injection@11.6.0': + dependencies: + '@sentry/core': 11.6.0 + '@sentry/server-utils': 11.6.0 - '@speed-highlight/core@1.2.24': {} + '@sentry/server-utils@11.6.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/conventions': 0.26.0 + '@sentry/core': 11.6.0 '@tybys/wasm-util@0.10.4': dependencies: @@ -2563,17 +2338,19 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - blake3-wasm@2.1.5: {} + balanced-match@4.0.4: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 chai@6.3.0: {} chalk@5.6.2: {} - cookie@1.1.1: {} - detect-libc@2.1.2: {} - error-stack-parser-es@1.0.5: {} + dotenv@17.4.2: {} es-module-lexer@2.3.2: {} @@ -2605,6 +2382,7 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + optional: true estree-walker@3.0.3: dependencies: @@ -2642,14 +2420,20 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + has-flag@4.0.0: {} + jiti@2.7.0: {} js-tokens@10.0.0: {} jsonc-parser@3.3.1: {} - kleur@4.1.5: {} - knip@6.40.0: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -2723,9 +2507,7 @@ snapshots: optionalDependencies: yaml: 2.9.1 - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 + lru-cache@11.5.3: {} magic-string@1.4.3: dependencies: @@ -2737,18 +2519,11 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.2 - miniflare@5.20261006.1-alpha(@types/node@24.19.1): + minimatch@10.2.6: dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.35.5(@types/node@24.19.1) - undici: 7.29.1 - workerd: 1.20261006.1 - ws: 8.21.0 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - utf-8-validate + brace-expansion: 5.0.12 + + minipass@7.1.3: {} nanoid@3.3.20: {} @@ -2778,6 +2553,30 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.150.0 '@oxc-parser/binding-win32-x64-msvc': 0.150.0 + oxc-parser@0.152.0: + dependencies: + '@oxc-project/types': 0.152.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.152.0 + '@oxc-parser/binding-android-arm64': 0.152.0 + '@oxc-parser/binding-darwin-arm64': 0.152.0 + '@oxc-parser/binding-darwin-x64': 0.152.0 + '@oxc-parser/binding-freebsd-x64': 0.152.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.152.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.152.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.152.0 + '@oxc-parser/binding-linux-arm64-musl': 0.152.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.152.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.152.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.152.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.152.0 + '@oxc-parser/binding-linux-x64-gnu': 0.152.0 + '@oxc-parser/binding-linux-x64-musl': 0.152.0 + '@oxc-parser/binding-openharmony-arm64': 0.152.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.152.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.152.0 + '@oxc-parser/binding-win32-x64-msvc': 0.152.0 + oxc-resolver@11.24.2: optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.24.2 @@ -2858,9 +2657,10 @@ snapshots: package-manager-detector@1.9.0: {} - path-to-regexp@6.3.0: {} - - pathe@2.0.3: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 picocolors@1.1.1: {} @@ -2895,40 +2695,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.13 '@rolldown/binding-win32-x64-msvc': 1.2.13 - semver@7.8.5: {} - - sharp@0.35.5(@types/node@24.19.1): - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.5 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.5 - '@img/sharp-darwin-x64': 0.35.5 - '@img/sharp-freebsd-wasm32': 0.35.5 - '@img/sharp-libvips-darwin-arm64': 1.3.4 - '@img/sharp-libvips-darwin-x64': 1.3.4 - '@img/sharp-libvips-linux-arm': 1.3.4 - '@img/sharp-libvips-linux-arm64': 1.3.4 - '@img/sharp-libvips-linux-ppc64': 1.3.4 - '@img/sharp-libvips-linux-riscv64': 1.3.4 - '@img/sharp-libvips-linux-s390x': 1.3.4 - '@img/sharp-libvips-linux-x64': 1.3.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 - '@img/sharp-libvips-linuxmusl-x64': 1.3.4 - '@img/sharp-linux-arm': 0.35.5 - '@img/sharp-linux-arm64': 0.35.5 - '@img/sharp-linux-ppc64': 0.35.5 - '@img/sharp-linux-riscv64': 0.35.5 - '@img/sharp-linux-s390x': 0.35.5 - '@img/sharp-linux-x64': 0.35.5 - '@img/sharp-linuxmusl-arm64': 0.35.5 - '@img/sharp-linuxmusl-x64': 0.35.5 - '@img/sharp-webcontainers-wasm32': 0.35.5 - '@img/sharp-win32-arm64': 0.35.5 - '@img/sharp-win32-ia32': 0.35.5 - '@img/sharp-win32-x64': 0.35.5 - '@types/node': 24.19.1 + sentry@0.45.0: {} simple-git-hooks@2.14.0: {} @@ -2944,7 +2711,9 @@ snapshots: strip-json-comments@5.0.3: {} - supports-color@10.2.2: {} + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 tinybench@6.2.1: {} @@ -2989,14 +2758,8 @@ snapshots: undici-types@7.24.6: {} - undici@7.29.1: {} - undici@8.11.2: {} - unenv@2.0.0-rc.24: - dependencies: - pathe: 2.0.3 - vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -3038,47 +2801,6 @@ snapshots: why-is-node-running@3.2.1: {} - workerd@1.20261006.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20261006.1 - '@cloudflare/workerd-darwin-arm64': 1.20261006.1 - '@cloudflare/workerd-linux-64': 1.20261006.1 - '@cloudflare/workerd-linux-arm64': 1.20261006.1 - '@cloudflare/workerd-windows-64': 1.20261006.1 - - wrangler@4.149.0(@cloudflare/workers-types@5.20261008.1)(@types/node@24.19.1): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1) - blake3-wasm: 2.1.5 - esbuild: 0.28.2 - miniflare: 5.20261006.1-alpha(@types/node@24.19.1) - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20261006.1 - optionalDependencies: - '@cloudflare/workers-types': 5.20261008.1 - fsevents: 2.3.3 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - utf-8-validate - - ws@8.21.0: {} - yaml@2.9.1: {} - youch-core@0.3.3: - dependencies: - '@poppinss/exception': 1.2.3 - error-stack-parser-es: 1.0.5 - - youch@4.1.0-beta.10: - dependencies: - '@poppinss/colors': 4.1.6 - '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.24 - cookie: 1.1.1 - youch-core: 0.3.3 - zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8517a11..3a85d6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,4 +4,3 @@ minimumReleaseAge: 1440 allowBuilds: esbuild: true simple-git-hooks: true - workerd: true diff --git a/src/certificates.ts b/src/certificates.ts index 48e9bb7..ef90a87 100644 --- a/src/certificates.ts +++ b/src/certificates.ts @@ -1,9 +1,11 @@ /** * The certificate authority of Roach. * - * The proxy intercepts HTTPS. It creates its own certificate authority when - * it starts, and signs one certificate for each host when a client first - * connects to that host. Clients must trust the authority certificate. + * The proxy intercepts HTTPS. It signs one certificate for each host when a + * client first connects to that host, and signs it again before it + * expires. Clients must trust the authority certificate. A local proxy + * creates a new authority when it starts. The shared service loads a fixed + * one, so that clients can trust it across restarts. */ import { execFile } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; @@ -33,35 +35,41 @@ export interface CertificateAuthority { close(): Promise; } -/** Create a certificate authority in a new temporary directory. */ -export async function createCertificateAuthority(): Promise { +/** A PEM certificate authority, such as the one of a deployed service. */ +export interface CertificateAuthorityPem { + cert: string; + key: string; +} + +/** Days that a host certificate is valid. */ +const HOST_DAYS = 7; +/** Sign a host again when its certificate is older than this. */ +const RESIGN_MS = (HOST_DAYS - 1) * 24 * 60 * 60 * 1000; + +/** + * Create a certificate authority in a new temporary directory. Without + * `pem`, it creates a new authority that is valid for 7 days. + */ +export async function createCertificateAuthority( + pem?: CertificateAuthorityPem, +): Promise { const directory = await mkdtemp(path.join(tmpdir(), "roach-ca-")); const caKey = path.join(directory, "ca.key"); const caCertFile = path.join(directory, "ca.crt"); const hostKey = path.join(directory, "host.key"); - await openssl([ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-nodes", - "-keyout", - caKey, - "-out", - caCertFile, - "-days", - "7", - "-subj", - "/CN=Roach CA", - "-addext", - "basicConstraints=critical,CA:TRUE", - "-addext", - "keyUsage=critical,keyCertSign,cRLSign", - ]); + if (pem) { + await writeFile(caKey, pem.key, { mode: 0o600 }); + await writeFile(caCertFile, pem.cert); + } else { + await createAuthority(caKey, caCertFile); + } // All hosts share one key. Only their certificates differ. await openssl(["genrsa", "-out", hostKey, "2048"]); const hostKeyPem = await readFile(hostKey, "utf8"); - const contexts = new Map>(); + const contexts = new Map< + string, + { signed: number; context: Promise } + >(); const sign = async (host: string): Promise => { const name = createHash("sha256").update(host).digest("hex").slice(0, 16); @@ -96,7 +104,7 @@ export async function createCertificateAuthority(): Promise RESIGN_MS) { + const fresh = { signed: now, context: sign(host) }; + // A failed signature must not stay in the cache. + fresh.context.catch(() => { + if (contexts.get(host) === fresh) contexts.delete(host); + }); + contexts.set(host, fresh); + entry = fresh; } - return context; + return entry.context; }, close: () => rm(directory, { force: true, recursive: true }), }; } + +/** Create a new authority that is valid for 7 days. */ +async function createAuthority(caKey: string, caCertFile: string) { + await openssl([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + caKey, + "-out", + caCertFile, + "-days", + "7", + "-subj", + "/CN=Roach CA", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ]); +} diff --git a/src/cli.ts b/src/cli.ts index 9131867..bd17c32 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,24 +1,54 @@ /** * The command line of Roach. * - * - `cli.ts serve`: read the configuration as JSON from stdin, and start the - * proxy. Print its address as one JSON line. Stop on SIGINT or SIGTERM. - * `client.ts` runs this command. + * - `cli.ts service `: start the shared service (`service.ts`) + * with the configuration in the file. Print its URL as one JSON line. + * Stop on SIGINT or SIGTERM. The deployed container runs this command. + * - `cli.ts serve`: read the configuration of a local proxy as JSON from + * stdin, and start it. Print its address as one JSON line. Stop on SIGINT + * or SIGTERM. `client.ts` runs this command. * - `cli.ts prune ...`: delete the recordings that no * used file lists. */ +import { readFile } from "node:fs/promises"; import { text } from "node:stream/consumers"; import { pruneRecordings } from "./recordings.ts"; import { startRoach } from "./server.ts"; +import type { RoachServiceConfig } from "./service.ts"; import type { RoachAddress, RoachConfig } from "./types.ts"; const USAGE = `Usage: + cli.ts service cli.ts serve < config.json cli.ts prune ...`; const [command, ...args] = process.argv.slice(2); -if (command === "serve") { +/** Stop on SIGINT or SIGTERM. A failed close logs its error and exits 1. */ +function stopOnSignal(close: () => Promise): void { + const stop = () => { + close().then( + () => process.exit(0), + (error: unknown) => { + process.stderr.write(`[roach] Close failed: ${String(error)}\n`); + process.exit(1); + }, + ); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); +} + +if (command === "service" && args.length === 1) { + const config = JSON.parse( + await readFile(args[0]!, "utf8"), + ) as RoachServiceConfig; + // Only the service loads the Sentry SDK. + const { startRoachService } = await import("./service.ts"); + const service = await startRoachService(config); + process.stdout.write(`${JSON.stringify({ url: service.url })}\n`); + stopOnSignal(() => service.close()); +} else if (command === "serve") { const config = JSON.parse(await text(process.stdin)) as RoachConfig; const proxy = await startRoach(config); const address: RoachAddress = { @@ -27,11 +57,7 @@ if (command === "serve") { caCert: proxy.caCert, }; process.stdout.write(`${JSON.stringify(address)}\n`); - const stop = () => { - void proxy.close().finally(() => process.exit(0)); - }; - process.once("SIGINT", stop); - process.once("SIGTERM", stop); + stopOnSignal(() => proxy.close()); } else if (command === "prune" && args.length >= 2) { const [directory, ...usedFiles] = args as [string, ...string[]]; const count = await pruneRecordings(directory, usedFiles); diff --git a/src/client.ts b/src/client.ts index f1d5ef0..e5b1193 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,18 +1,21 @@ /** * The client of Roach. See `README.md`. * - * `spawnRoach()` starts the proxy in its own process and returns - * `env`, the variables that send the traffic of a process through it. - * `connectRoach()` controls a running proxy from another process, - * such as a test worker. + * `startRemoteRun()` starts a run on a Roach service (`service.ts`). + * `spawnRoach()` starts a local proxy in its own process. Both return + * `env`, the variables that send the traffic of a process through the + * proxy. `connectRoach()` controls a run or a local proxy from another + * process, such as a test worker. */ import { spawn } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import http from "node:http"; +import https from "node:https"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { CONTROL_PATH } from "./server.ts"; +import type { RemoteRun, RunConfig } from "./service.ts"; import type { RoachAddress, RoachConfig, RecordingStats } from "./types.ts"; /** The open session of one test. */ @@ -57,23 +60,33 @@ const PROXY_VARIABLES = new Set([ "node_use_env_proxy", ]); +/** Where the control API is, and its token. */ +type ControlAddress = Pick & { + token?: string | undefined; +}; + /** Call the control API, and return the JSON of the response. */ function callControl( - address: Pick, - method: "GET" | "POST", + address: ControlAddress, + method: "DELETE" | "GET" | "POST", route: string, body?: unknown, ): Promise { const payload = body === undefined ? undefined : JSON.stringify(body); + const base = address.controlUrl ?? `${address.url}${CONTROL_PATH}`; + const url = route ? `${base}/${route}` : base; + const client = url.startsWith("https:") ? https : http; return new Promise((resolve, reject) => { - const request = http.request( - `${address.url}${CONTROL_PATH}/${route}`, + const request = client.request( + url, { // Its own agent, so that a proxy agent of the process is not used. agent: false, method, headers: { - authorization: `Bearer ${address.token}`, + ...(address.token + ? { authorization: `Bearer ${address.token}` } + : {}), ...(payload ? { "content-type": "application/json" } : {}), }, }, @@ -101,9 +114,9 @@ function callControl( }); } -/** Control a running proxy, for example from a test worker. */ +/** Control a run or a local proxy, for example from a test worker. */ export function connectRoach( - address: Pick, + address: Pick, ): RoachControl { return { async startSession(name) { @@ -168,14 +181,73 @@ export async function spawnRoach( ); child.stdin.end(JSON.stringify(config)); const address = await readAddress(child); + const proxyEnv = await createProxyEnv(address, noProxy); + + return { + ...address, + ...connectRoach(address), + env: proxyEnv.env, + async close() { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + await exited; + } + await proxyEnv.remove(); + }, + }; +} + +/** A run on a Roach service (`service.ts`). */ +export interface RemoteRoach extends RemoteRun, RoachControl { + /** The same variables as `Roach.env`, for the proxy URL of the run. */ + env: Record; + /** End the run. Returns its stats. */ + close(): Promise; +} + +/** + * Start a run on a Roach service, such as `https://roach.example.com`. + * + * `token` is the token of the tenant. Only this call uses it. Without it, + * the service only allows `replay` mode, so CI jobs of forks can replay + * without a secret. The run and its workers use the run token. + */ +export async function startRemoteRun( + service: { url: string; token?: string | undefined }, + config: RunConfig, + { noProxy = "localhost,127.0.0.1,::1" }: { noProxy?: string } = {}, +): Promise { + const run = await callControl( + { url: service.url.replace(/\/$/, ""), token: service.token }, + "POST", + "runs", + config, + ); + const proxyEnv = await createProxyEnv(run, noProxy); + return { + ...run, + ...connectRoach(run), + env: proxyEnv.env, + async close() { + try { + return await callControl(run, "DELETE", ""); + } finally { + await proxyEnv.remove(); + } + }, + }; +} + +/** The proxy variables of a process, with the CA certificate in a file. */ +async function createProxyEnv( + address: Pick, + noProxy: string, +): Promise<{ env: Record; remove(): Promise }> { // `NODE_EXTRA_CA_CERTS` takes a file. const caDirectory = await mkdtemp(path.join(tmpdir(), "roach-")); const caFile = path.join(caDirectory, "ca.pem"); await writeFile(caFile, address.caCert); - return { - ...address, - ...connectRoach(address), env: { HTTP_PROXY: address.url, HTTPS_PROXY: address.url, @@ -183,12 +255,6 @@ export async function spawnRoach( NODE_USE_ENV_PROXY: "1", NODE_EXTRA_CA_CERTS: caFile, }, - async close() { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGTERM"); - await exited; - } - await rm(caDirectory, { force: true, recursive: true }); - }, + remove: () => rm(caDirectory, { force: true, recursive: true }), }; } diff --git a/src/gcs.ts b/src/gcs.ts new file mode 100644 index 0000000..82617b2 --- /dev/null +++ b/src/gcs.ts @@ -0,0 +1,145 @@ +/** + * The GCS store: the recordings of the shared service (`service.ts`) in a + * Google Cloud Storage bucket. + * + * Each recording is the object ``. A lifecycle rule of the + * bucket deletes it 30 days after it was written (`deploy/gcp/`), so + * nothing here deletes recordings. + * + * The store calls the JSON API with `fetch`, and takes its access token + * from the metadata server of the VM. So it needs no key file and no + * Google SDK. Two standard variables change the hosts, for local tests: + * `STORAGE_EMULATOR_HOST` (a URL) and `GCE_METADATA_HOST` (a host). + */ +import { + formatRecording, + type Recording, + type RecordingStore, +} from "./store.ts"; + +/** A call that takes longer than this fails. */ +const TIMEOUT_MS = 30_000; +/** Tries of one call. GCS asks clients to retry 429 and 5xx. */ +const TRIES = 3; +/** Get a new token when the current one expires within this time. */ +const TOKEN_MARGIN_MS = 60_000; + +const isTemporary = (status: number) => status === 429 || status >= 500; +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** `fetch`, with a timeout and retries of temporary failures. */ +async function call(url: string, init: RequestInit): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!isTemporary(response.status) || attempt === TRIES) return response; + await response.body?.cancel(); + } catch (error) { + if (attempt === TRIES) throw error; + } + await wait(250 * 2 ** attempt); + } +} + +/** The access token of the VM service account, from the metadata server. */ +function createTokenSource(metadataHost: string): () => Promise { + let cached: { token: string; expires: number } | undefined; + return async () => { + if (cached && cached.expires - TOKEN_MARGIN_MS > Date.now()) { + return cached.token; + } + const response = await call( + `http://${metadataHost}/computeMetadata/v1/instance/service-accounts/default/token`, + { headers: { "metadata-flavor": "Google" } }, + ); + if (!response.ok) { + throw new Error( + `GCE metadata token failed with HTTP ${response.status}: ${await response.text()}`, + ); + } + const body = (await response.json()) as { + access_token: string; + expires_in: number; + }; + cached = { + token: body.access_token, + expires: Date.now() + body.expires_in * 1000, + }; + return cached.token; + }; +} + +/** Where the store sends its calls. */ +interface GcsHosts { + /** The base URL of the storage API. */ + storage: string; + /** The host of the metadata server. */ + metadata: string; +} + +/** The hosts of GCS, or the ones of the standard variables. */ +function gcsHosts(env = process.env): GcsHosts { + return { + storage: ( + env.STORAGE_EMULATOR_HOST ?? "https://storage.googleapis.com" + ).replace(/\/$/, ""), + metadata: env.GCE_METADATA_HOST ?? "metadata.google.internal", + }; +} + +/** The store of the recordings in `bucket`, under `prefix`. */ +export function createGcsStore(bucket: string, prefix: string): RecordingStore { + const hosts = gcsHosts(); + const token = createTokenSource(hosts.metadata); + const bucketPath = `b/${encodeURIComponent(bucket)}/o`; + + const writeOne = async ([key, recording]: [string, Recording]) => { + const name = encodeURIComponent(`${prefix}${key}`); + const response = await call( + `${hosts.storage}/upload/storage/v1/${bucketPath}?uploadType=media&name=${name}`, + { + method: "POST", + headers: { + authorization: `Bearer ${await token()}`, + "content-type": "application/json", + }, + body: formatRecording(recording), + }, + ); + if (!response.ok) { + throw new Error( + `GCS write of ${key} failed with HTTP ${response.status}: ${await response.text()}`, + ); + } + await response.body?.cancel(); + }; + + return { + async read(key) { + const name = encodeURIComponent(`${prefix}${key}`); + const response = await call( + `${hosts.storage}/storage/v1/${bucketPath}/${name}?alt=media`, + { headers: { authorization: `Bearer ${await token()}` } }, + ); + if (response.status === 404) { + await response.body?.cancel(); + return undefined; + } + if (!response.ok) { + throw new Error( + `GCS read of ${key} failed with HTTP ${response.status}: ${await response.text()}`, + ); + } + return (await response.json()) as Recording; + }, + + // GCS does not say if an object changed, so each write counts. + async write(recordings) { + await Promise.all(recordings.map(writeOne)); + return recordings.length; + }, + }; +} diff --git a/src/recorder.ts b/src/recorder.ts index 756aa24..4781479 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -5,7 +5,7 @@ * rule matches, with a function that sends the request live. The recorder * decides whether to replay, send live, or fail the request, and it owns * every read and write of the recordings. A store keeps them: the file - * store (`recordings.ts`) or a Roach Worker (`remote-store.ts`). + * store (`recordings.ts`) or a GCS bucket (`gcs.ts`). * * A session groups the requests of one test. One session is open at a * time. The recorder keeps the new recordings of a session in memory until @@ -20,8 +20,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import type { IncomingHttpHeaders } from "node:http"; import path from "node:path"; -import { createFileStore } from "./recordings.ts"; -import { createRemoteStore } from "./remote-store.ts"; import { describeParts, keyRequest, type KeyedRequest } from "./request-key.ts"; import { createSecrets } from "./secrets.ts"; import { @@ -147,42 +145,24 @@ function fromRecording( return { status: response.status, headers: response.headers, body }; } -/** The store of a config. Throws when the config does not name one. */ -function storeOf(config: RoachConfig): RecordingStore { - if ((config.directory === undefined) === (config.store === undefined)) { - throw new Error("Roach config must set directory or store, not both"); - } +/** The parts of a config that the recorder uses. */ +export type RecorderConfig = Pick< + RoachConfig, + "missDirectory" | "mode" | "rules" | "secrets" | "usedFile" +>; + +/** Create the recorder of one proxy run, with the store of its recordings. */ +export function createRecorder(config: RecorderConfig, store: RecordingStore) { for (const rule of config.rules) { if (!RULE_NAME.test(rule.name)) { throw new Error(`Roach rule name is not valid: ${rule.name}`); } } - if (config.store) { - if (config.usedFile) throw new Error("usedFile needs directory"); - return createRemoteStore(config.store); - } - return createFileStore(config.directory!); -} - -/** Create the recorder of one proxy run. */ -export function createRecorder(config: RoachConfig) { - const store = storeOf(config); const missDirectory = config.missDirectory ? path.resolve(config.missDirectory) : undefined; - if ( - missDirectory && - config.directory !== undefined && - !path.relative(config.directory, missDirectory).startsWith("..") - ) { - // Miss files hold request bodies. Keep them out of the committed files. - throw new Error("missDirectory must not be inside directory"); - } let session: Session | undefined; - const secrets = createSecrets([ - ...(config.secrets ?? []), - ...(config.store ? [config.store.token] : []), - ]); + const secrets = createSecrets(config.secrets); /** Recordings that passed sessions, or requests outside one, used. */ const used = new Set(); const stats: RecordingStats = { diff --git a/src/remote-store.ts b/src/remote-store.ts deleted file mode 100644 index 7307965..0000000 --- a/src/remote-store.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * The remote store: the recordings are in a Roach Worker (`worker/`). - * - * The proxy calls the Worker directly, not through itself, with the token - * of its tenant and the name of its run. "Worker" in `README.md` has the - * routes. A miss has no closest recording, because the Worker keeps no - * index of request parts. - */ -import { - formatRecording, - type Recording, - type RecordingStore, -} from "./store.ts"; -import type { RemoteStoreConfig } from "./types.ts"; - -/** A call that takes longer than this fails. */ -const TIMEOUT_MS = 30_000; -/** Writes that run at the same time. */ -const WRITE_CONCURRENCY = 8; -const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); - -/** The Worker URL. Plain HTTP is only for a local Worker. */ -function parseBase(value: string): URL { - const url = new URL(value.endsWith("/") ? value : `${value}/`); - if ( - url.protocol !== "https:" && - !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) - ) { - throw new Error(`Roach store URL must use https: ${value}`); - } - return url; -} - -/** The store of the recordings in the Worker of `config`. */ -export function createRemoteStore(config: RemoteStoreConfig): RecordingStore { - const base = parseBase(config.url); - - const call = async ( - method: "GET" | "PUT", - key: string, - body?: string, - ): Promise => { - const response = await fetch(new URL(`v1/recordings/${key}`, base), { - method, - headers: { - authorization: `Bearer ${config.token}`, - ...(config.run === undefined ? {} : { "x-roach-run": config.run }), - ...(body === undefined ? {} : { "content-type": "application/json" }), - }, - ...(body === undefined ? {} : { body }), - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok && !(method === "GET" && response.status === 404)) { - throw new Error( - `Roach store ${method} ${key} failed with HTTP ${response.status}: ${await response.text()}`, - ); - } - return response; - }; - - const writeOne = async ([key, recording]: [string, Recording]) => { - const response = await call("PUT", key, formatRecording(recording)); - return ((await response.json()) as { changed: boolean }).changed; - }; - - return { - async read(key) { - const response = await call("GET", key); - if (response.status === 404) { - await response.body?.cancel(); - return undefined; - } - return (await response.json()) as Recording; - }, - - async write(recordings) { - let changed = 0; - for (let at = 0; at < recordings.length; at += WRITE_CONCURRENCY) { - const results = await Promise.all( - recordings.slice(at, at + WRITE_CONCURRENCY).map(writeOne), - ); - changed += results.filter(Boolean).length; - } - return changed; - }, - }; -} diff --git a/src/server.ts b/src/server.ts index 645d623..4ecec9f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,12 +19,17 @@ import http from "node:http"; import https from "node:https"; import { isIP, type Socket } from "node:net"; import tls from "node:tls"; -import { createCertificateAuthority } from "./certificates.ts"; +import { + createCertificateAuthority, + type CertificateAuthority, +} from "./certificates.ts"; import { createRecorder, type ProxyRequest, type Recorder, } from "./recorder.ts"; +import path from "node:path"; +import { createFileStore } from "./recordings.ts"; import type { RoachAddress, RoachConfig } from "./types.ts"; /** The path prefix of the control API. */ @@ -48,9 +53,15 @@ const HOP_HEADERS = new Set([ "upgrade", ]); const AUTHORITY = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+):(\d{1,5})$/i; +/** A request body over this size gets HTTP 413. */ +const MAX_BODY_BYTES = 64 * 1024 * 1024; + +/** An error that answers a request with its own status. */ +const httpError = (status: number, message: string) => + Object.assign(new Error(message), { status }); /** Parse the allowed origins, by origin. A value that is not one throws. */ -function parseOrigins(values: string[]): Map { +export function parseOrigins(values: string[]): Map { return new Map( values.map((value) => { const origin = new URL(value); @@ -74,18 +85,31 @@ function authorityOf(origin: URL): string { function readBody(stream: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; - stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + let size = 0; + stream.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + // Read the rest without keeping it, so the client gets the 413. + chunks.length = 0; + reject(httpError(413, `body is over ${MAX_BODY_BYTES} bytes`)); + return; + } + chunks.push(chunk); + }); stream.on("end", () => resolve(Buffer.concat(chunks))); stream.on("error", reject); }); } -async function readJson(incoming: http.IncomingMessage): Promise { +export async function readJson(incoming: http.IncomingMessage): Promise { return JSON.parse((await readBody(incoming)).toString("utf8") || "{}") as T; } /** Compare tokens in constant time. */ -function sameToken(actual: string | undefined, expected: string): boolean { +export function sameToken( + actual: string | undefined, + expected: string, +): boolean { if (actual === undefined) return false; const a = Buffer.from(actual); const b = Buffer.from(expected); @@ -190,97 +214,196 @@ async function proxyRequest( outgoing.end(response.body); } -/** Answer one request to the control API. See `README.md`. */ -async function control( +/** Write a JSON response. */ +export function sendJson( + outgoing: http.ServerResponse, + status: number, + body: unknown, +): void { + outgoing.writeHead(status, { "content-type": "application/json" }); + outgoing.end(JSON.stringify(body)); +} + +/** + * Answer a session or stats call of one recorder. `route` is the method + * and the path after the prefix of the run, such as `POST /session`. + * Returns `false` when the route is not one of them. + */ +export async function controlRecorder( recorder: Recorder, - token: string, + route: string, incoming: http.IncomingMessage, outgoing: http.ServerResponse, -): Promise { - if (!sameToken(incoming.headers.authorization, `Bearer ${token}`)) { - outgoing.writeHead(401).end(); - return; - } - const route = `${incoming.method} ${new URL(incoming.url ?? "/", "http://proxy").pathname}`; - const json = (status: number, body: unknown) => { - outgoing.writeHead(status, { "content-type": "application/json" }); - outgoing.end(JSON.stringify(body)); - }; +): Promise { switch (route) { - case `GET ${CONTROL_PATH}/stats`: - json(200, recorder.stats()); - return; - case `POST ${CONTROL_PATH}/session`: { + case "GET /stats": + sendJson(outgoing, 200, recorder.stats()); + return true; + case "POST /session": { const { name } = await readJson<{ name?: unknown }>(incoming); if (typeof name !== "string" || name === "") { - json(400, { error: "name must be a string" }); - return; + sendJson(outgoing, 400, { error: "name must be a string" }); + return true; } await recorder.startSession(name); outgoing.writeHead(204).end(); - return; + return true; } - case `POST ${CONTROL_PATH}/session/end`: { + case "POST /session/end": { const { name, passed } = await readJson<{ name?: unknown; passed?: unknown; }>(incoming); try { const missed = await recorder.endSession(String(name), passed === true); - json(200, { missed }); + sendJson(outgoing, 200, { missed }); } catch (error) { - json(409, { error: (error as Error).message }); + sendJson(outgoing, 409, { error: (error as Error).message }); } - return; + return true; } default: - outgoing.writeHead(404).end(); + return false; } } -/** Start the proxy in this process, on a free port of `127.0.0.1`. */ -export async function startRoach(config: RoachConfig): Promise { - const origins = parseOrigins(config.allow); - const allowedAuthorities = new Set([...origins.values()].map(authorityOf)); - const recorder = createRecorder(config); - const authority = await createCertificateAuthority(); - const token = randomBytes(24).toString("hex"); - // The origin of each tunnel. Absolute-form requests have no tunnel. - const tunnelOrigins = new WeakMap(); - const sockets = new Set(); +/** Answer one request to the control API of a local proxy. */ +async function control( + recorder: Recorder, + token: string, + incoming: http.IncomingMessage, + outgoing: http.ServerResponse, +): Promise { + if (!sameToken(incoming.headers.authorization, `Bearer ${token}`)) { + outgoing.writeHead(401).end(); + return; + } + const pathname = new URL(incoming.url ?? "/", "http://proxy").pathname; + const handled = + pathname.startsWith(`${CONTROL_PATH}/`) && + (await controlRecorder( + recorder, + `${incoming.method} ${pathname.slice(CONTROL_PATH.length)}`, + incoming, + outgoing, + )); + if (!handled) outgoing.writeHead(404).end(); +} + +/** Where a proxied request goes: the allowed origins and the recorder. */ +export interface ProxyTarget { + origins: Map; + recorder: Recorder; + /** Set when the target stops. Its tunnels then refuse new requests. */ + closed?: boolean; +} + +/** A target, or the HTTP status that refuses the request. */ +export type TargetResult = ProxyTarget | { status: 403 | 407 }; + +/** The options of `listenProxy()`. */ +export interface ListenOptions { + host: string; + port: number; + authority: CertificateAuthority; + /** + * The target of a proxied request. It reads `Proxy-Authorization` of a + * `CONNECT` or an absolute-form request. + */ + targetFor(incoming: http.IncomingMessage): TargetResult; + /** Answer a request to the proxy itself, such as the control API. */ + control( + incoming: http.IncomingMessage, + outgoing: http.ServerResponse, + ): Promise; +} + +/** A listening proxy socket. */ +export interface ListeningProxy { + port: number; + close(): Promise; +} - const serve = - (originOf: (incoming: http.IncomingMessage) => string | undefined) => - (incoming: http.IncomingMessage, outgoing: http.ServerResponse) => { - proxyRequest( - recorder, - origins, - incoming, - outgoing, - originOf(incoming), - ).catch((error: unknown) => { - if (!outgoing.headersSent) { - outgoing.writeHead(502, { "content-type": "text/plain" }); +/** Refuse a proxied request that has no target. */ +function refuse(status: 403 | 407, outgoing: http.ServerResponse): void { + outgoing.writeHead( + status, + status === 407 + ? { + "proxy-authenticate": 'Basic realm="roach"', + "content-type": "text/plain", } - outgoing.end( - `Roach error: ${error instanceof Error ? error.message : String(error)}\n`, - ); - }); - }; + : { "content-type": "text/plain" }, + ); + outgoing.end( + `Roach: ${status === 407 ? "proxy credentials required" : "forbidden"}\n`, + ); +} + +/** Proxy one request through its target, and answer errors with 502. */ +function serveProxied( + target: ProxyTarget, + incoming: http.IncomingMessage, + outgoing: http.ServerResponse, + tunnelOrigin: string | undefined, +): void { + if (target.closed) { + outgoing.writeHead(410, { "content-type": "text/plain" }); + outgoing.end("Roach: this run has ended\n"); + return; + } + proxyRequest( + target.recorder, + target.origins, + incoming, + outgoing, + tunnelOrigin, + ).catch((error: unknown) => { + if (!outgoing.headersSent) { + const status = (error as { status?: number }).status ?? 502; + outgoing.writeHead(status, { "content-type": "text/plain" }); + } + outgoing.end( + `Roach error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }); +} + +/** + * Listen for proxied requests and control requests on one port. The + * caller decides the target of each proxied request, so one port can serve + * one run (`startRoach()`) or many runs (`service.ts`). + */ +export async function listenProxy( + options: ListenOptions, +): Promise { + const { authority } = options; + // The origin and target of each tunnel. Absolute-form requests have none. + const tunnels = new WeakMap< + Socket, + { origin: string; target: ProxyTarget } + >(); + const sockets = new Set(); // Requests inside a CONNECT tunnel. The tunnel gives the origin. - const tunnelServer = http.createServer( - serve((incoming) => tunnelOrigins.get(incoming.socket)), - ); - const proxyAbsolute = serve(() => undefined); + const tunnelServer = http.createServer((incoming, outgoing) => { + const tunnel = tunnels.get(incoming.socket); + if (!tunnel) { + outgoing.writeHead(400).end(); + return; + } + serveProxied(tunnel.target, incoming, outgoing, tunnel.origin); + }); const server = http.createServer((incoming, outgoing) => { // A request to the proxy itself has a path. A proxied plain HTTP // request has an absolute URL. if (!incoming.url?.startsWith("/")) { - proxyAbsolute(incoming, outgoing); + const target = options.targetFor(incoming); + if ("status" in target) refuse(target.status, outgoing); + else serveProxied(target, incoming, outgoing, undefined); return; } - control(recorder, token, incoming, outgoing).catch((error: unknown) => { + options.control(incoming, outgoing).catch((error: unknown) => { process.stderr.write(`[roach] Control failed: ${String(error)}\n`); if (!outgoing.headersSent) outgoing.writeHead(500); outgoing.end(); @@ -290,72 +413,123 @@ export async function startRoach(config: RoachConfig): Promise { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); }); - server.on("connect", (request, socket: Socket, head: Buffer) => { - socket.on("error", () => socket.destroy()); - const match = AUTHORITY.exec(request.url ?? ""); - if (!match) { - socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); - return; - } - const [, host, port] = match as unknown as [string, string, string]; - if (!allowedAuthorities.has(`${host.toLowerCase()}:${port}`)) { - process.stderr.write(`[roach] Refused ${host}:${port}\n`); - socket.end("HTTP/1.1 403 Forbidden\r\n\r\n"); - return; - } - socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - const open = async (first: Buffer) => { - socket.unshift(first); - // A TLS handshake starts with byte 0x16. Anything else is plain HTTP. - if (first[0] !== 0x16) { - tunnelOrigins.set( - socket, - `http://${host}${port === "80" ? "" : `:${port}`}`, + server.on( + "connect", + (request: http.IncomingMessage, socket: Socket, head: Buffer) => { + socket.on("error", () => socket.destroy()); + const target = options.targetFor(request); + if ("status" in target) { + socket.end( + target.status === 407 + ? 'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="roach"\r\n\r\n' + : "HTTP/1.1 403 Forbidden\r\n\r\n", ); - tunnelServer.emit("connection", socket); - socket.resume(); return; } - const secure = new tls.TLSSocket(socket, { - isServer: true, - secureContext: await authority.contextFor(host), - ALPNProtocols: ["http/1.1"], - }); - secure.on("error", () => secure.destroy()); - tunnelOrigins.set( - secure, - `https://${host}${port === "443" ? "" : `:${port}`}`, + const match = AUTHORITY.exec(request.url ?? ""); + if (!match) { + socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); + return; + } + const [, host, port] = match as unknown as [string, string, string]; + const allowed = [...target.origins.values()].some( + (origin) => authorityOf(origin) === `${host.toLowerCase()}:${port}`, ); - tunnelServer.emit("connection", secure); - }; - const onFirst = (first: Buffer) => { - socket.pause(); - open(first).catch(() => socket.destroy()); - }; - if (head.length > 0) onFirst(head); - else socket.once("data", onFirst); - }); + if (!allowed) { + process.stderr.write(`[roach] Refused ${host}:${port}\n`); + socket.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + const open = async (first: Buffer) => { + socket.unshift(first); + // A TLS handshake starts with byte 0x16. Anything else is plain HTTP. + if (first[0] !== 0x16) { + tunnels.set(socket, { + origin: `http://${host}${port === "80" ? "" : `:${port}`}`, + target, + }); + tunnelServer.emit("connection", socket); + socket.resume(); + return; + } + const secure = new tls.TLSSocket(socket, { + isServer: true, + secureContext: await authority.contextFor(host), + ALPNProtocols: ["http/1.1"], + }); + secure.on("error", () => secure.destroy()); + tunnels.set(secure, { + origin: `https://${host}${port === "443" ? "" : `:${port}`}`, + target, + }); + tunnelServer.emit("connection", secure); + }; + const onFirst = (first: Buffer) => { + socket.pause(); + open(first).catch(() => socket.destroy()); + }; + if (head.length > 0) onFirst(head); + else socket.once("data", onFirst); + }, + ); await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); + server.listen(options.port, options.host, () => resolve()); }); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Roach did not bind to a TCP port"); } - return { - url: `http://127.0.0.1:${address.port}`, - token, - caCert: authority.caCert, + port: address.port, async close() { await new Promise((resolve) => { server.close(() => resolve()); for (const socket of sockets) socket.destroy(); }); tunnelServer.close(); - await recorder.close(); + }, + }; +} + +/** Start the proxy in this process, on a free port of `127.0.0.1`. */ +export async function startRoach(config: RoachConfig): Promise { + if ( + config.missDirectory && + !path + .relative(config.directory, path.resolve(config.missDirectory)) + .startsWith("..") + ) { + // Miss files hold request bodies. Keep them out of the committed files. + throw new Error("missDirectory must not be inside directory"); + } + const target: ProxyTarget = { + origins: parseOrigins(config.allow), + recorder: createRecorder(config, createFileStore(config.directory)), + }; + const authority = await createCertificateAuthority(); + const token = randomBytes(24).toString("hex"); + // Only local processes reach this port, so proxied requests need no + // credentials. + const listening = await listenProxy({ + host: "127.0.0.1", + port: 0, + authority, + targetFor: () => target, + control: (incoming, outgoing) => + control(target.recorder, token, incoming, outgoing), + }); + + return { + url: `http://127.0.0.1:${listening.port}`, + token, + caCert: authority.caCert, + async close() { + target.closed = true; + await listening.close(); + await target.recorder.close(); await authority.close(); }, }; diff --git a/src/service.ts b/src/service.ts new file mode 100644 index 0000000..f2b64ce --- /dev/null +++ b/src/service.ts @@ -0,0 +1,446 @@ +/** + * Roach as a shared service. See "Service" in `README.md`. + * + * One process serves the runs of many tenants on one port. A tenant is a + * project, such as Junior. A run is one test run of a tenant, such as one + * CI job. It has its own mode, rules, sessions, and stats, as one local + * proxy (`server.ts`) has. + * + * - Anyone can create a `replay` run of a tenant, so CI jobs of forks + * replay recordings without a secret. Such a run never writes. + * - A run in another mode needs the token of the tenant, because it + * writes recordings. The service keeps only the SHA-256 of each token. + * - The service gives each run its own id and token. A proxied request + * names its run in `Proxy-Authorization`, as `Basic base64(:)`. + * The proxy URL of the run has them, so `HTTPS_PROXY` sends them. + * - The recordings of a tenant are under `/`, in a directory or a + * GCS bucket (`gcs.ts`). Tenants never share recordings, because a key + * does not include credentials. + * - Each read and write of a recording sends the Sentry metric + * `roach.recording`, with the tenant, the key, and the run. + */ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type http from "node:http"; +import path from "node:path"; +import * as Sentry from "@sentry/node"; +import { + createCertificateAuthority, + type CertificateAuthorityPem, +} from "./certificates.ts"; +import { createGcsStore } from "./gcs.ts"; +import { createRecorder } from "./recorder.ts"; +import { createFileStore } from "./recordings.ts"; +import { + CONTROL_PATH, + controlRecorder, + listenProxy, + parseOrigins, + readJson, + sameToken, + sendJson, + type ProxyTarget, + type TargetResult, +} from "./server.ts"; +import { RULE_NAME, type RecordingStore } from "./store.ts"; +import type { RecordingMode, RecordingRule, RecordingStats } from "./types.ts"; +import { VALUE_PATTERNS } from "./values.ts"; + +/** The configuration of the service. The operator owns it. */ +export interface RoachServiceConfig { + /** Default: `127.0.0.1`. */ + host?: string; + /** Default: a free port. */ + port?: number; + /** + * The URL that clients use to reach the service, such as + * `https://roach.example.com`. Default: `http://:`. + */ + publicUrl?: string; + /** A directory for the recordings. Set this or `bucket`. */ + directory?: string; + /** A GCS bucket for the recordings. Set this or `directory`. */ + bucket?: string; + /** The only origins that runs can reach. A run can allow fewer. */ + allow: string[]; + /** + * The tenants, by name. Each value is the SHA-256 of the token of the + * tenant, in hex. + */ + tenants: Record; + /** + * More regular expression sources that rules can use in `values`, in + * addition to `VALUE_PATTERNS`. A slow pattern blocks all runs, so check + * each one for catastrophic backtracking before you add it. + */ + valuePatterns?: string[]; + /** Credentials to redact in all recordings. */ + secrets?: string[]; + /** + * The certificate authority that signs intercepted hosts. Clients trust + * it, so a deployed service must keep it across restarts. Default: a new + * one at each start. + */ + ca?: CertificateAuthorityPem; + /** Without a DSN, the service sends nothing to Sentry. */ + sentryDsn?: string; +} + +/** The configuration of one run. The client sends it. */ +export interface RunConfig { + /** The tenant of the run. */ + tenant: string; + mode: RecordingMode; + rules: RecordingRule[]; + /** Default: all origins of the service. */ + allow?: string[]; + /** + * The name of the run in Sentry, such as + * `${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`. Default: the run id. + */ + name?: string; +} + +/** A run that the service created. */ +export interface RemoteRun { + /** The id of the run. */ + id: string; + /** The proxy URL, with the run id and token as its user and password. */ + url: string; + /** The run token. Session and stats calls use it. */ + token: string; + /** The base URL of the control API of the run. */ + controlUrl: string; + /** The PEM certificate of the authority that signs intercepted hosts. */ + caCert: string; +} + +/** A service that runs in this process. */ +export interface RoachService { + /** The URL of the service. */ + url: string; + /** Stop the service. Open runs fail. */ + close(): Promise; +} + +interface Run extends ProxyTarget { + id: string; + token: string; + started: number; +} + +const MODES = new Set(["auto", "off", "record", "replay"]); +/** A run that is open longer than this ends as failed, such as a dead job. */ +const RUN_TIMEOUT_MS = 6 * 60 * 60 * 1000; +const MAX_RUN_NAME = 128; + +const sha256 = (value: string) => createHash("sha256").update(value).digest(); + +/** Check that `token` is the token of `tenant`, in constant time. */ +function isTenantToken( + tenants: Record, + tenant: string, + authorization: string | undefined, +): boolean { + const token = /^Bearer (\S+)$/.exec(authorization ?? "")?.[1]; + const expected = Object.hasOwn(tenants, tenant) + ? Buffer.from(tenants[tenant]!, "hex") + : undefined; + if (!token || !expected) return false; + const actual = sha256(token); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +/** The run id and token in a `Proxy-Authorization` header. */ +function proxyCredentials( + header: string | undefined, +): { id: string; token: string } | undefined { + const match = /^Basic (.+)$/i.exec(header ?? ""); + if (!match) return undefined; + const decoded = Buffer.from(match[1]!, "base64").toString("utf8"); + const colon = decoded.indexOf(":"); + if (colon <= 0) return undefined; + return { id: decoded.slice(0, colon), token: decoded.slice(colon + 1) }; +} + +/** Check the run config of a client. Returns the error, or `undefined`. */ +function invalidRunConfig( + config: Partial, + serviceOrigins: Map, + valuePatterns: Set, +): string | undefined { + if (!config.mode || !MODES.has(config.mode)) { + return "mode must be auto, off, record, or replay"; + } + if (config.name !== undefined) { + if (typeof config.name !== "string" || config.name.length > MAX_RUN_NAME) { + return `name must be a string of at most ${MAX_RUN_NAME} characters`; + } + } + if (!Array.isArray(config.rules)) return "rules must be a list"; + const names = new Set(); + for (const rule of config.rules) { + if (typeof rule?.name !== "string" || !RULE_NAME.test(rule.name)) { + return `rule name must match ${RULE_NAME}`; + } + if (names.has(rule.name)) return `rule ${rule.name} is not unique`; + names.add(rule.name); + if (typeof rule.match !== "object" || rule.match === null) { + return `rule ${rule.name} must have match`; + } + // A pattern runs on the requests of all runs in one process, so a + // client can only use patterns that the operator knows to be fast. + if (rule.values !== undefined) { + if (typeof rule.values !== "object" || rule.values === null) { + return `values of rule ${rule.name} must be an object`; + } + for (const [name, source] of Object.entries(rule.values)) { + if (typeof source !== "string" || !valuePatterns.has(source)) { + return `value ${name} of rule ${rule.name} is not a pattern that the service allows`; + } + } + } + } + if (config.allow !== undefined) { + if (!Array.isArray(config.allow)) return "allow must be a list"; + for (const origin of config.allow) { + // The client sends JSON, so check the type at run time. + if ( + typeof origin !== "string" || + !serviceOrigins.has(origin.replace(/\/$/, "")) + ) { + return `the service does not allow ${origin}`; + } + } + } + return undefined; +} + +/** + * The store of one run. It counts each read and write in Sentry, and a + * run without write access cannot write. + */ +function runStore( + base: RecordingStore, + labels: { tenant: string; run: string }, + canWrite: boolean, +): RecordingStore { + const count = (key: string, result: string) => { + Sentry.metrics.count("roach.recording", 1, { + attributes: { + ...labels, + rule: key.slice(0, key.indexOf("/")), + key, + result, + }, + }); + }; + return { + async read(key) { + const recording = await base.read(key); + count(key, recording ? "replayed" : "missed"); + return recording; + }, + async write(recordings) { + if (recordings.length === 0) return 0; + if (!canWrite) throw new Error("This run cannot write recordings"); + const written = await base.write(recordings); + for (const [key] of recordings) count(key, "written"); + return written; + }, + }; +} + +/** Start the service in this process. */ +export async function startRoachService( + config: RoachServiceConfig, +): Promise { + if ((config.directory === undefined) === (config.bucket === undefined)) { + throw new Error("Roach service config must set directory or bucket"); + } + for (const [tenant, hash] of Object.entries(config.tenants)) { + if (!RULE_NAME.test(tenant)) { + throw new Error(`Roach tenant name must match ${RULE_NAME}: ${tenant}`); + } + if (!/^[0-9a-f]{64}$/i.test(hash)) { + throw new Error(`Roach tenant ${tenant} needs a SHA-256 token hash`); + } + } + if (config.sentryDsn) { + Sentry.init({ dsn: config.sentryDsn, tracesSampleRate: 0 }); + } + const serviceOrigins = parseOrigins(config.allow); + const runs = new Map(); + const authority = await createCertificateAuthority(config.ca); + const valuePatterns = new Set([ + ...Object.values(VALUE_PATTERNS), + ...(config.valuePatterns ?? []), + ]); + const storeOf = (tenant: string): RecordingStore => + config.bucket + ? createGcsStore(config.bucket, `${tenant}/`) + : createFileStore(path.join(config.directory!, tenant)); + let publicUrl = config.publicUrl?.replace(/\/$/, ""); + + const endRun = async (run: Run): Promise => { + runs.delete(run.id); + run.closed = true; + await run.recorder.close(); + return run.recorder.stats(); + }; + + const createRun = (runConfig: RunConfig, canWrite: boolean): RemoteRun => { + const id = randomBytes(8).toString("hex"); + const token = randomBytes(24).toString("hex"); + const allow = runConfig.allow ?? config.allow; + const store = runStore( + storeOf(runConfig.tenant), + { tenant: runConfig.tenant, run: runConfig.name ?? id }, + canWrite, + ); + const run: Run = { + id, + token, + started: Date.now(), + origins: parseOrigins(allow.map((origin) => origin.replace(/\/$/, ""))), + // Each run learns only the credentials of its own requests. + recorder: createRecorder( + { + mode: runConfig.mode, + rules: runConfig.rules, + ...(config.secrets ? { secrets: config.secrets } : {}), + }, + store, + ), + }; + runs.set(id, run); + process.stderr.write( + `[roach] Run ${id} of ${runConfig.tenant} started in ${runConfig.mode} mode\n`, + ); + const base = new URL(publicUrl!); + base.username = id; + base.password = token; + return { + id, + url: base.href.replace(/\/$/, ""), + token, + controlUrl: `${publicUrl}${CONTROL_PATH}/runs/${id}`, + caCert: authority.caCert, + }; + }; + + const targetFor = (incoming: http.IncomingMessage): TargetResult => { + const credentials = proxyCredentials( + incoming.headers["proxy-authorization"], + ); + const run = credentials && runs.get(credentials.id); + if (!run || !sameToken(credentials.token, run.token)) { + return { status: 407 }; + } + return run; + }; + + const control = async ( + incoming: http.IncomingMessage, + outgoing: http.ServerResponse, + ): Promise => { + const pathname = new URL(incoming.url ?? "/", "http://proxy").pathname; + if (incoming.method === "GET" && pathname === `${CONTROL_PATH}/ca.pem`) { + outgoing.writeHead(200, { "content-type": "application/x-pem-file" }); + outgoing.end(authority.caCert); + return; + } + if (incoming.method === "POST" && pathname === `${CONTROL_PATH}/runs`) { + const runConfig = await readJson>(incoming); + const tenant = runConfig.tenant; + if ( + typeof tenant !== "string" || + !Object.hasOwn(config.tenants, tenant) + ) { + sendJson(outgoing, 400, { error: "tenant is not known" }); + return; + } + const error = invalidRunConfig(runConfig, serviceOrigins, valuePatterns); + if (error) { + sendJson(outgoing, 400, { error }); + return; + } + const canWrite = isTenantToken( + config.tenants, + tenant, + incoming.headers.authorization, + ); + // Reads are public. Every other mode can write, so it needs the token. + if (!canWrite && runConfig.mode !== "replay") { + sendJson(outgoing, 401, { + error: `${runConfig.mode} mode needs the token of ${tenant}`, + }); + return; + } + sendJson(outgoing, 201, createRun(runConfig as RunConfig, canWrite)); + return; + } + + const runRoute = new RegExp( + `^${CONTROL_PATH}/runs/([0-9a-f]+)(/.*)?$`, + ).exec(pathname); + const run = runRoute && runs.get(runRoute[1]!); + if ( + !run || + !sameToken(incoming.headers.authorization, `Bearer ${run.token}`) + ) { + // The same answer for a run that does not exist, so ids stay secret. + outgoing.writeHead(404).end(); + return; + } + const rest = runRoute[2] ?? ""; + if (incoming.method === "DELETE" && rest === "") { + sendJson(outgoing, 200, await endRun(run)); + return; + } + const handled = await controlRecorder( + run.recorder, + `${incoming.method} ${rest}`, + incoming, + outgoing, + ); + if (!handled) outgoing.writeHead(404).end(); + }; + + const host = config.host ?? "127.0.0.1"; + const listening = await listenProxy({ + host, + port: config.port ?? 0, + authority, + targetFor, + control, + }); + publicUrl ??= `http://${host.includes(":") ? `[${host}]` : host}:${listening.port}`; + + // End runs that their client never ended, such as a CI job that died. + const sweep = setInterval(() => { + const now = Date.now(); + for (const run of runs.values()) { + if (now - run.started > RUN_TIMEOUT_MS) { + process.stderr.write(`[roach] Run ${run.id} timed out\n`); + // A failed close must not stop the service for other runs. + endRun(run).catch((error: unknown) => { + process.stderr.write( + `[roach] Run ${run.id} did not end: ${String(error)}\n`, + ); + }); + } + } + }, 60_000); + sweep.unref(); + + return { + url: publicUrl, + async close() { + clearInterval(sweep); + await listening.close(); + await Promise.all([...runs.values()].map(endRun)); + await authority.close(); + await Sentry.flush(2000); + }, + }; +} diff --git a/src/store.ts b/src/store.ts index 3f3a0a8..f287d74 100644 --- a/src/store.ts +++ b/src/store.ts @@ -7,9 +7,8 @@ * * A store keeps recordings by key, `/.json`. The file store * (`recordings.ts`) keeps them in a directory that can be committed. The - * remote store (`remote-store.ts`) keeps them in the Worker (`worker/`). - * - * This file uses no Node built-ins, because the Worker uses it too. + * GCS store (`gcs.ts`) keeps them in a bucket, for the shared service + * (`service.ts`). */ /** A short hash of each part of a request, by part name. */ export type RequestParts = Record; @@ -48,7 +47,7 @@ export interface RecordingStore { * The recording of `rule` with the most equal parts. Recordings of the * same session come first, because a test usually sends the same * requests as the last time it ran. Only the file store has this: it is a - * hint for debugging a miss, and the Worker keeps no index for it. + * hint for debugging a miss, and a bucket has no index for it. */ closest?( rule: string, @@ -65,9 +64,6 @@ export interface RecordingStore { /** A rule name that a store accepts. It is a directory name. */ export const RULE_NAME = /^[A-Za-z0-9][\w.-]{0,63}$/; -/** A key that a store accepts: `/.json`. */ -export const RECORDING_KEY = /^([A-Za-z0-9][\w.-]{0,63})\/[0-9a-f]{64}\.json$/; - /** The key of a request of `rule` with the hash `hash`. */ export const recordingKey = (rule: string, hash: string) => `${rule}/${hash}.json`; diff --git a/src/types.ts b/src/types.ts index 292e207..8de368b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,29 +42,13 @@ export interface RecordingRule { values?: ValuePatterns; } -/** A Roach Worker (`worker/`) that keeps the recordings. */ -export interface RemoteStoreConfig { - /** The URL of the Worker, such as `https://roach.example.workers.dev`. */ - url: string; - /** - * The token of the tenant. The proxy sends it only to `url`, and never - * writes it to a recording. - */ - token: string; - /** - * The run of this proxy, such as the GitHub Actions run id. The Worker - * counts the runs that use each recording, so a recording that only one - * run uses shows up in Sentry. - */ - run?: string; -} - -/** The configuration of one proxy. Set `directory` or `store`. */ +/** + * The configuration of one local proxy (`server.ts`). The shared service + * (`service.ts`) has its own configuration. + */ export interface RoachConfig { /** The directory of the recordings. Each rule has a subdirectory. */ - directory?: string; - /** The Worker that keeps the recordings, in place of `directory`. */ - store?: RemoteStoreConfig; + directory: string; mode: RecordingMode; /** * The only origins that the proxy sends requests to, such as @@ -75,8 +59,7 @@ export interface RoachConfig { /** * When the proxy stops, it lists here the recordings that sessions used. * A failed session uses all recordings that it recorded before. Give these - * files to the `prune` command. Only for `directory`: R2 deletes the recordings - * of the Worker 30 days after they were written. + * files to the `prune` command. */ usedFile?: string; /** @@ -134,6 +117,8 @@ export interface RoachAddress { url: string; /** The bearer token of the control API. */ token: string; + /** The base URL of the control API. Default: `/__roach`. */ + controlUrl?: string; /** The PEM certificate of the authority that signs intercepted hosts. */ caCert: string; } diff --git a/tests/ci-job.ts b/tests/ci-job.ts new file mode 100644 index 0000000..f40d11c --- /dev/null +++ b/tests/ci-job.ts @@ -0,0 +1,49 @@ +/** + * One CI job that uses a deployed Roach service. `deployed.test.ts` runs it + * in its own process, because it must trust the TLS certificate of the + * service at startup, as a CI job trusts a public one. + * + * It starts a run, then runs a test process that has only the proxy + * variables of the run. That process sends one HTTPS request with plain + * `fetch`. The job prints the response, the stats, and the CA certificate + * as one JSON line. + * + * Argument: JSON `{ service, token?, config, url, body }`. + */ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { startRemoteRun } from "../src/client.ts"; +import type { RunConfig } from "../src/service.ts"; + +const { service, token, config, url, body } = JSON.parse(process.argv[2]!) as { + service: string; + token?: string; + config: RunConfig; + url: string; + body: string; +}; + +const run = await startRemoteRun({ url: service, token }, config); +// The test process trusts the CA of Roach and the TLS certificate of the +// service, as this job does. +const caFile = run.env.NODE_EXTRA_CA_CERTS!; +await writeFile( + caFile, + `${await readFile(caFile, "utf8")}${await readFile(process.env.NODE_EXTRA_CA_CERTS!, "utf8")}`, +); +const session = await run.startSession("test"); +const script = ` +const response = await fetch(${JSON.stringify(url)}, { method: "POST", body: ${JSON.stringify(body)} }); +console.log(JSON.stringify({ status: response.status, source: response.headers.get("x-roach"), body: await response.text() })); +`; +const { stdout } = await promisify(execFile)( + process.execPath, + ["--input-type=module", "--eval", script], + { env: { ...process.env, ...run.env } }, +); +await session.end(true); +const stats = await run.close(); +process.stdout.write( + `${JSON.stringify({ response: JSON.parse(stdout), stats, caCert: run.caCert })}\n`, +); diff --git a/tests/deployed.test.ts b/tests/deployed.test.ts new file mode 100644 index 0000000..2198cfa --- /dev/null +++ b/tests/deployed.test.ts @@ -0,0 +1,335 @@ +/** + * Roach as it runs in production (`deploy/gcp/`), on one machine. + * + * - The service runs from the command line, with a config file, a fixed + * certificate authority, a GCS bucket, and Sentry. + * - A TLS server in front of it does what the load balancer does: it ends + * TLS and passes the bytes to the service. + * - A local server plays the GCS JSON API and the metadata server of the + * VM. Another one plays Sentry. + * - Each CI job (`ci-job.ts`) sends HTTPS through the service with only the + * proxy variables of its run. + */ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import https from "node:https"; +import net, { type AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import tls from "node:tls"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { RoachServiceConfig, RunConfig } from "../src/service.ts"; + +const execFileAsync = promisify(execFile); +const ROOT = path.join(import.meta.dirname, ".."); +const TOKEN = "junior-write-token"; +const BUCKET = "roach-recordings"; +const GCS_TOKEN = "vm-access-token"; + +let directory: string; +let hostCertFile: string; +let caCert: string; +const objects = new Map(); +const metrics: Array> = []; +let liveRequests = 0; +let upstreamOrigin: string; +let frontUrl: string; +let service: ChildProcess; +const servers: Array<{ close(callback: () => void): unknown }> = []; + +/** The environment of a child process, without proxy variables. */ +function childEnv(extra: Record): NodeJS.ProcessEnv { + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !/^(https?_proxy|no_proxy|all_proxy)$/i.test(name), + ), + ); + return { ...env, ...extra }; +} + +async function listen(server: net.Server, host = "127.0.0.1") { + servers.push(server); + server.listen(0, host); + await once(server, "listening"); + return (server.address() as AddressInfo).port; +} + +/** Keep the `roach.recording` metrics of a Sentry envelope. */ +function readEnvelope(envelope: string) { + for (const line of envelope.split("\n")) { + if (!line.includes('"roach.recording"')) continue; + const { items } = JSON.parse(line) as { + items: Array<{ attributes: Record }>; + }; + for (const { attributes } of items) { + metrics.push( + Object.fromEntries( + Object.entries(attributes).map(([name, { value }]) => [name, value]), + ), + ); + } + } +} + +/** Run one CI job, and return what it printed. */ +async function ciJob(token: string | undefined, mode: RunConfig["mode"]) { + const config: RunConfig = { + tenant: "junior", + mode, + name: `${mode}-run`, + rules: [{ name: "model", match: { method: "POST" } }], + }; + const { stdout } = await execFileAsync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + path.join(ROOT, "tests/ci-job.ts"), + JSON.stringify({ + service: frontUrl, + token, + config, + url: `${upstreamOrigin}/v1/messages`, + body: JSON.stringify({ prompt: "hi" }), + }), + ], + { env: childEnv({ NODE_EXTRA_CA_CERTS: hostCertFile }) }, + ); + return JSON.parse(stdout) as { + response: { status: number; source: string; body: string }; + stats: { written: number }; + caCert: string; + }; +} + +beforeAll(async () => { + directory = await mkdtemp(path.join(tmpdir(), "roach-deployed-")); + // A fixed authority, as Terraform makes it, and one certificate for the + // TLS server and the upstream. + const caKeyFile = path.join(directory, "ca.key"); + const caCertFile = path.join(directory, "ca.crt"); + hostCertFile = path.join(directory, "host.crt"); + const hostKeyFile = path.join(directory, "host.key"); + await execFileAsync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-keyout", + caKeyFile, + "-out", + caCertFile, + "-subj", + "/CN=Test Roach CA", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ]); + await execFileAsync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-keyout", + hostKeyFile, + "-out", + hostCertFile, + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:127.0.0.2", + ]); + caCert = await readFile(caCertFile, "utf8"); + const host = { + cert: await readFile(hostCertFile, "utf8"), + key: await readFile(hostKeyFile, "utf8"), + }; + + // The upstream API. It is not on `localhost`, so `NO_PROXY` keeps it. + const upstream = https.createServer(host, (_incoming, outgoing) => { + liveRequests += 1; + outgoing.writeHead(200, { "content-type": "application/json" }); + outgoing.end(JSON.stringify({ n: liveRequests })); + }); + upstreamOrigin = `https://127.0.0.2:${await listen(upstream, "127.0.0.2")}`; + + // The GCS JSON API and the metadata server of the VM. + const gcs = createServer((incoming, outgoing) => { + const url = new URL(incoming.url!, "http://gcs"); + if (url.pathname.startsWith("/computeMetadata/")) { + if (incoming.headers["metadata-flavor"] !== "Google") { + outgoing.writeHead(403).end(); + return; + } + outgoing.end( + JSON.stringify({ access_token: GCS_TOKEN, expires_in: 3600 }), + ); + return; + } + if (incoming.headers.authorization !== `Bearer ${GCS_TOKEN}`) { + outgoing.writeHead(401).end(); + return; + } + const upload = `/upload/storage/v1/b/${BUCKET}/o`; + const read = `/storage/v1/b/${BUCKET}/o/`; + if (incoming.method === "POST" && url.pathname === upload) { + let body = ""; + incoming.on("data", (chunk: Buffer) => (body += chunk.toString())); + incoming.on("end", () => { + objects.set(url.searchParams.get("name")!, body); + outgoing.end("{}"); + }); + return; + } + if (incoming.method === "GET" && url.pathname.startsWith(read)) { + const object = objects.get( + decodeURIComponent(url.pathname.slice(read.length)), + ); + if (object === undefined) outgoing.writeHead(404).end(); + else outgoing.end(object); + return; + } + outgoing.writeHead(404).end(); + }); + const gcsHost = `127.0.0.1:${await listen(gcs)}`; + + const sentry = createServer((incoming, outgoing) => { + let envelope = ""; + incoming.setEncoding("utf8"); + incoming.on("data", (chunk: string) => (envelope += chunk)); + incoming.on("end", () => { + readEnvelope(envelope); + outgoing.end("{}"); + }); + }); + const sentryPort = await listen(sentry); + + // The service listens on a free port. The TLS server takes its place. + const probe = net.createServer(); + const servicePort = await listen(probe); + await new Promise((resolve) => probe.close(resolve)); + servers.splice(servers.indexOf(probe), 1); + const front = tls.createServer(host, (socket) => { + const backend = net.connect(servicePort, "127.0.0.1"); + socket.pipe(backend).pipe(socket); + backend.on("error", () => socket.destroy()); + socket.on("error", () => backend.destroy()); + }); + frontUrl = `https://localhost:${await listen(front)}`; + + const config: RoachServiceConfig = { + host: "127.0.0.1", + port: servicePort, + publicUrl: frontUrl, + bucket: BUCKET, + allow: [upstreamOrigin], + tenants: { junior: createHash("sha256").update(TOKEN).digest("hex") }, + ca: { cert: caCert, key: await readFile(caKeyFile, "utf8") }, + sentryDsn: `http://public@127.0.0.1:${sentryPort}/1`, + }; + const configFile = path.join(directory, "config.json"); + await writeFile(configFile, JSON.stringify(config)); + service = spawn( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + path.join(ROOT, "src/cli.ts"), + "service", + configFile, + ], + { + env: childEnv({ + STORAGE_EMULATOR_HOST: `http://${gcsHost}`, + GCE_METADATA_HOST: gcsHost, + // The service trusts the upstream, as it trusts public APIs. + NODE_EXTRA_CA_CERTS: hostCertFile, + }), + stdio: ["ignore", "pipe", "inherit"], + }, + ); + const [line] = (await once(service.stdout!, "data")) as [Buffer]; + const { url } = JSON.parse(line.toString()) as { url: string }; + if (url !== frontUrl) throw new Error(`The service printed ${url}`); +}); + +afterAll(async () => { + if (service.exitCode === null) { + service.kill("SIGTERM"); + await once(service, "exit"); + } + await Promise.all( + servers.map( + (server) => new Promise((resolve) => server.close(() => resolve())), + ), + ); + await rm(directory, { recursive: true, force: true }); +}); + +describe("deployed roach", () => { + it("records with the token, replays for a fork, and counts in Sentry", async () => { + const writer = await ciJob(TOKEN, "auto"); + expect(writer.response).toEqual({ + status: 200, + source: "live", + body: JSON.stringify({ n: 1 }), + }); + expect(writer.stats.written).toBe(1); + // A restart keeps the authority that clients trust. + expect(writer.caCert).toBe(caCert); + expect([...objects.keys()]).toEqual([ + expect.stringMatching(/^junior\/model\/[0-9a-f]{64}\.json$/), + ]); + + const fork = await ciJob(undefined, "replay"); + expect(fork.response).toEqual({ + status: 200, + source: "replayed", + body: JSON.stringify({ n: 1 }), + }); + expect(liveRequests).toBe(1); + + // The service sends its metrics when it stops. + service.kill("SIGTERM"); + expect(await once(service, "exit")).toEqual([0, null]); + const [key] = objects.keys(); + expect( + metrics.map(({ tenant, run, result, key: metricKey }) => ({ + tenant, + run, + result, + key: metricKey, + })), + ).toEqual([ + { + tenant: "junior", + run: "auto-run", + result: "missed", + key: key!.slice(7), + }, + { + tenant: "junior", + run: "auto-run", + result: "written", + key: key!.slice(7), + }, + { + tenant: "junior", + run: "replay-run", + result: "replayed", + key: key!.slice(7), + }, + ]); + }); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts new file mode 100644 index 0000000..4f20363 --- /dev/null +++ b/tests/service.test.ts @@ -0,0 +1,240 @@ +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ProxyAgent, request } from "undici"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { startRemoteRun, type RemoteRoach } from "../src/client.ts"; +import { + startRoachService, + type RoachService, + type RunConfig, +} from "../src/service.ts"; +import { VALUE_PATTERNS } from "../src/values.ts"; + +let upstream: Server; +let origin: string; +let liveRequests: number; +let directory: string; +let service: RoachService; +const agents: ProxyAgent[] = []; +const runs: RemoteRoach[] = []; + +const hash = (token: string) => + createHash("sha256").update(token).digest("hex"); + +/** Start a run, with one recorded rule. Without `token`, it has no token. */ +async function startRun( + token: string | undefined, + mode: RunConfig["mode"], + config: Partial = {}, +): Promise { + const run = await startRemoteRun( + { url: service.url, token }, + { + tenant: "alpha", + mode, + rules: [ + { name: "model", match: { method: "POST", url: `${origin}/v1/` } }, + ], + ...config, + }, + ); + runs.push(run); + return run; +} + +/** Send one request through the proxy URL of a run. */ +async function send( + proxyUrl: string, + body: unknown, + headers: Record = {}, +) { + // Tunnel plain HTTP too, so the tests use `CONNECT` as HTTPS clients do. + const agent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }); + agents.push(agent); + const response = await request(`${origin}/v1/messages`, { + body: JSON.stringify(body), + dispatcher: agent, + headers, + method: "POST", + }); + return { + status: response.statusCode, + body: await response.body.text(), + source: response.headers["x-roach"], + }; +} + +async function recordings(tenant: string): Promise { + return readdir(path.join(directory, tenant, "model")).catch(() => []); +} + +beforeEach(async () => { + liveRequests = 0; + upstream = createServer((incoming, outgoing) => { + liveRequests += 1; + // Echo a credential, as an API that shows the key of the caller does. + outgoing.writeHead(200, { "content-type": "application/json" }); + outgoing.end( + JSON.stringify({ + n: liveRequests, + key: incoming.headers["x-api-key"] ?? null, + serviceSecret: "service-secret-value-123", + }), + ); + }); + await new Promise((resolve) => + upstream.listen(0, "127.0.0.1", resolve), + ); + const address = upstream.address(); + if (!address || typeof address === "string") throw new Error("No port"); + origin = `http://127.0.0.1:${address.port}`; + directory = await mkdtemp(path.join(tmpdir(), "roach-service-")); + service = await startRoachService({ + directory, + allow: [origin], + tenants: { alpha: hash("alpha-token"), beta: hash("beta-token") }, + secrets: ["service-secret-value-123"], + }); +}); + +afterEach(async () => { + await Promise.all(agents.splice(0).map((agent) => agent.close())); + await Promise.all(runs.splice(0).map((run) => run.close().catch(() => {}))); + await service.close(); + await new Promise((resolve) => upstream.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); +}); + +describe("roach service", () => { + it("writes with the tenant token and replays for anyone", async () => { + const writer = await startRun("alpha-token", "auto"); + expect(await send(writer.url, { prompt: "hi" })).toMatchObject({ + source: "live", + }); + expect(await writer.close()).toMatchObject({ written: 1 }); + + // A fork has no token. It can still replay the recordings of a tenant. + const reader = await startRun(undefined, "replay"); + expect(await send(reader.url, { prompt: "hi" })).toMatchObject({ + status: 200, + source: "replayed", + }); + expect(await send(reader.url, { prompt: "new" })).toMatchObject({ + status: 412, + source: "missed", + }); + // Without the token, no mode that can write. + for (const mode of ["auto", "record", "off"] as const) { + await expect(startRun(undefined, mode)).rejects.toThrow(/401/); + } + // The token of another tenant does not count. + await expect(startRun("beta-token", "auto")).rejects.toThrow(/401/); + + // Another tenant has its own recordings. + const beta = await startRun(undefined, "replay", { tenant: "beta" }); + expect(await send(beta.url, { prompt: "hi" })).toMatchObject({ + status: 412, + }); + expect(liveRequests).toBe(1); + expect(await recordings("alpha")).toHaveLength(1); + expect(await recordings("beta")).toEqual([]); + }); + + it("keeps the sessions of concurrent runs apart", async () => { + const first = await startRun("alpha-token", "auto"); + const second = await startRun("alpha-token", "auto"); + const failing = await first.startSession("test"); + const passing = await second.startSession("test"); + + // Both sessions are open at once. Each request belongs to its own run. + await send(first.url, { prompt: "from first" }); + await send(second.url, { prompt: "from second" }); + await failing.end(false); + await passing.end(true); + + expect(await recordings("alpha")).toHaveLength(1); + const [file] = await recordings("alpha"); + const recording = JSON.parse( + await readFile(path.join(directory, "alpha", "model", file!), "utf8"), + ); + expect(recording.session).toBe("test"); + expect(JSON.parse(recording.response.body).n).toBe(2); + expect((await first.stats()).discarded).toBe(1); + expect((await second.stats()).written).toBe(1); + }); + + it("refuses callers without the right credentials", async () => { + const run = await startRun("alpha-token", "auto"); + const bare = new URL(run.url); + bare.username = ""; + bare.password = ""; + await expect(send(bare.href, { prompt: "hi" })).rejects.toThrow(/407/); + // An absolute-form request needs the credentials too. + const absolute = new ProxyAgent({ uri: bare.href, proxyTunnel: false }); + agents.push(absolute); + await expect( + request(`${origin}/v1/messages`, { dispatcher: absolute }), + ).rejects.toThrow(/407/); + const wrong = new URL(run.url); + wrong.password = "not-the-token"; + await expect(send(wrong.href, { prompt: "hi" })).rejects.toThrow(/407/); + + await expect( + startRun("alpha-token", "auto", { tenant: "nobody" }), + ).rejects.toThrow(/tenant is not known/); + await expect( + startRun("alpha-token", "auto", { allow: ["https://example.com"] }), + ).rejects.toThrow(/does not allow/); + await expect( + startRun("alpha-token", "auto", { + rules: [{ name: "../escape", match: {} }], + }), + ).rejects.toThrow(/rule name/); + // A pattern runs for all runs, so only known patterns are allowed. + await expect( + startRun("alpha-token", "auto", { + rules: [{ name: "model", match: {}, values: { slow: "(a+)+$" } }], + }), + ).rejects.toThrow(/not a pattern that the service allows/); + await startRun("alpha-token", "auto", { + rules: [ + { name: "model", match: {}, values: { id: VALUE_PATTERNS.uuid } }, + ], + }); + + // A body over the limit gets HTTP 413 and never goes live. + const large = await send(run.url, { prompt: "x".repeat(65 * 1024 * 1024) }); + expect(large.status).toBe(413); + + // An ended run refuses its proxy URL. + await run.close(); + await expect(send(run.url, { prompt: "hi" })).rejects.toThrow(/407/); + expect(liveRequests).toBe(0); + }); + + it("redacts request credentials and the secrets of the service", async () => { + const run = await startRun("alpha-token", "auto"); + const response = await send( + run.url, + { prompt: "hi" }, + { "x-api-key": "sk-live-request-key-456" }, + ); + + // The client gets the redacted body, as a replay would give it. + expect(JSON.parse(response.body)).toEqual({ + n: 1, + key: "<>", + serviceSecret: "<>", + }); + const [file] = await recordings("alpha"); + const text = await readFile( + path.join(directory, "alpha", "model", file!), + "utf8", + ); + expect(text).not.toContain("sk-live-request-key-456"); + expect(text).not.toContain("service-secret-value-123"); + }); +}); diff --git a/tests/worker.test.ts b/tests/worker.test.ts deleted file mode 100644 index a0f5344..0000000 --- a/tests/worker.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { createHash } from "node:crypto"; -import { createServer, type Server } from "node:http"; -import { ProxyAgent, request } from "undici"; -import { createTestHarness, type TestHarness } from "wrangler"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, -} from "vitest"; -import { connectRoach } from "../src/client.ts"; -import { startRoach, type RoachServer } from "../src/server.ts"; -import type { RecordingMode } from "../src/types.ts"; - -/** The part of the Worker env that the tests read. */ -interface TestEnv { - RECORDINGS: { - list(options: { prefix: string }): Promise<{ - objects: Array<{ key: string }>; - }>; - }; -} - -const sha256 = (text: string) => - createHash("sha256").update(text).digest("hex"); -const TOKENS = { alpha: "alpha-token", beta: "beta-token" }; - -/** One `roach.recording` metric that the Worker sent to Sentry. */ -interface RecordingMetric { - key: string; - result: string; - run: string; - tenant: string; -} - -let harness: TestHarness; -let sentry: Server; -const metrics: RecordingMetric[] = []; -let workerUrl: string; -let env: TestEnv; -let upstream: Server; -let origin: string; -let liveRequests: number; -const proxies: Array<{ proxy: RoachServer; agent: ProxyAgent }> = []; - -/** Keep the `roach.recording` metrics of a Sentry envelope. */ -function readEnvelope(envelope: string) { - for (const line of envelope.split("\n")) { - if (!line.includes('"roach.recording"')) continue; - const { items } = JSON.parse(line) as { - items: Array<{ attributes: Record }>; - }; - for (const { attributes } of items) { - metrics.push({ - key: attributes.key?.value ?? "", - result: attributes.result?.value ?? "", - run: attributes.run?.value ?? "", - tenant: attributes.tenant?.value ?? "", - }); - } - } -} - -beforeAll(async () => { - // A local Sentry that keeps the metrics of the Worker. - sentry = createServer((incoming, outgoing) => { - let envelope = ""; - incoming.setEncoding("utf8"); - incoming.on("data", (chunk: string) => (envelope += chunk)); - incoming.on("end", () => { - readEnvelope(envelope); - outgoing.end("{}"); - }); - }); - await new Promise((resolve) => sentry.listen(0, "127.0.0.1", resolve)); - const sentryAddress = sentry.address(); - if (!sentryAddress || typeof sentryAddress === "string") { - throw new Error("No port"); - } - harness = createTestHarness({ - workers: [ - { - configPath: "./wrangler.jsonc", - secrets: { - ROACH_TENANTS: JSON.stringify( - Object.fromEntries( - Object.entries(TOKENS).map(([name, token]) => [ - name, - sha256(token), - ]), - ), - ), - SENTRY_DSN: `http://public@127.0.0.1:${sentryAddress.port}/1`, - }, - }, - ], - }); - workerUrl = (await harness.listen()).url.href; - env = (await harness.getWorker().getEnv()) as TestEnv; -}, 60_000); - -afterAll(async () => { - await harness.close(); - await new Promise((resolve) => sentry.close(() => resolve())); -}); - -beforeEach(async () => { - liveRequests = 0; - upstream = createServer((incoming, outgoing) => { - liveRequests += 1; - incoming.resume(); - incoming.on("end", () => { - outgoing.writeHead(200, { "content-type": "text/event-stream" }); - outgoing.end(`data: ${liveRequests}\n\n`); - }); - }); - await new Promise((resolve) => - upstream.listen(0, "127.0.0.1", resolve), - ); - const address = upstream.address(); - if (!address || typeof address === "string") throw new Error("No port"); - origin = `http://127.0.0.1:${address.port}`; -}); - -afterEach(async () => { - for (const { proxy, agent } of proxies.splice(0)) { - await agent.close(); - await proxy.close(); - } - await new Promise((resolve) => upstream.close(() => resolve())); -}); - -/** Start a proxy that keeps its recordings in the Worker. */ -async function start(mode: RecordingMode, token: string) { - const proxy = await startRoach({ - store: { url: workerUrl, token, run: "run-1" }, - mode, - allow: [origin], - rules: [{ name: "model", match: { method: "POST", url: `${origin}/v1/` } }], - }); - const agent = new ProxyAgent({ uri: proxy.url, proxyTunnel: true }); - proxies.push({ proxy, agent }); - return { proxy, agent }; -} - -/** Send the requests of one passed session. */ -async function session( - { proxy, agent }: Awaited>, - bodies: unknown[], -) { - const opened = await connectRoach(proxy).startSession("test"); - const sources: unknown[] = []; - for (const body of bodies) { - const response = await request(`${origin}/v1/messages`, { - body: JSON.stringify(body), - dispatcher: agent, - method: "POST", - }); - await response.body.dump(); - sources.push(response.headers["x-roach"]); - } - await opened.end(true); - return sources; -} - -/** The keys that `tenant` has in R2. */ -const keysOf = async (tenant: string) => - (await env.RECORDINGS.list({ prefix: `${tenant}/` })).objects.map((object) => - object.key.slice(tenant.length + 1), - ); - -const callWorker = ( - path: string, - token: string | undefined, - init: RequestInit = {}, -) => - fetch(new URL(path, workerUrl), { - ...init, - headers: token ? { authorization: `Bearer ${token}` } : {}, - }); - -const RECORDING = { - session: "test", - request: { method: "POST", url: "https://x/", parts: { method: "a" } }, - response: { status: 200, headers: {}, body: "ok", bodyEncoding: "utf8" }, -}; - -describe("worker", () => { - it("records, replays, and fails a miss in replay mode", async () => { - const recording = await start("auto", TOKENS.alpha); - await session(recording, [{ model: "m", messages: ["a", "b"] }]); - const stored = await keysOf("alpha"); - - const running = await start("replay", TOKENS.alpha); - const sources = await session(running, [ - { model: "m", messages: ["a", "b"] }, - { model: "m", messages: ["a", "c"] }, - ]); - - expect(sources).toEqual(["replayed", "missed"]); - expect(liveRequests).toBe(1); - expect(stored).toHaveLength(1); - // Each read and write of the recording is one metric in Sentry. - await expect - .poll(() => metrics.filter((metric) => metric.key === stored[0])) - .toEqual( - expect.arrayContaining( - ["missed", "written", "replayed"].map((result) => ({ - key: stored[0], - result, - run: "run-1", - tenant: "alpha", - })), - ), - ); - // The Worker keeps no index, so a miss has no closest recording. - await expect(connectRoach(running.proxy).stats()).resolves.toMatchObject({ - counts: { model: { live: 0, missed: 1, replayed: 1 } }, - misses: [{ file: expect.stringMatching(/^model\//), differs: [] }], - }); - }); - - it("keeps tenants apart and refuses bad requests", async () => { - const key = `model/${"b".repeat(64)}.json`; - const put = await callWorker(`/v1/recordings/${key}`, TOKENS.alpha, { - method: "PUT", - body: JSON.stringify(RECORDING), - }); - expect(await put.json()).toEqual({ changed: true }); - - const status = async ( - path: string, - token: string | undefined, - init?: RequestInit, - ) => (await callWorker(path, token, init)).status; - expect(await status(`/v1/recordings/${key}`, TOKENS.alpha)).toBe(200); - expect(await status(`/v1/recordings/${key}`, TOKENS.beta)).toBe(404); - expect(await status(`/v1/recordings/${key}`, undefined)).toBe(401); - expect(await status(`/v1/recordings/${key}`, "wrong")).toBe(401); - expect(await status("/v1/recordings/../x.json", TOKENS.alpha)).toBe(404); - expect(await status("/v1/recordings/model/x.json", TOKENS.alpha)).toBe(400); - const putBody = (body: string) => - status(`/v1/recordings/${key}`, TOKENS.beta, { method: "PUT", body }); - expect(await putBody("{}")).toBe(400); - expect(await putBody("x".repeat(10 * 1024 * 1024 + 1))).toBe(413); - }); -}); diff --git a/worker/index.ts b/worker/index.ts deleted file mode 100644 index 3dd8614..0000000 --- a/worker/index.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * The Roach Worker: a remote store for the recordings of many proxies. - * - * Each CI run still runs its own proxy. The proxy calls this Worker to read - * and write recordings (`src/remote-store.ts`). R2 keeps each recording at - * `/`. An R2 lifecycle rule deletes recordings 30 days after - * they were written, so nothing here deletes or indexes recordings. - * - * - Each tenant has a token. `ROACH_TENANTS` keeps only the SHA-256 of - * each token. A tenant reads and writes only its own recordings. - * - Each read and write sends the Sentry metric `roach.recording`, with the - * tenant, the key, the result, and the run of the proxy. A key that only - * one run uses is a recording that never replays. See "Worker" in - * `README.md`. - */ -import * as Sentry from "@sentry/cloudflare"; -import { RECORDING_KEY } from "../src/store.ts"; - -/** The bindings and variables of the Worker (`wrangler.jsonc`). */ -interface Env { - RECORDINGS: R2Bucket; - /** JSON: the SHA-256 hex of the token of each tenant, by tenant. */ - ROACH_TENANTS?: string; - /** Without a DSN, the Worker sends nothing to Sentry. */ - SENTRY_DSN?: string; -} - -/** What a request did with a recording. */ -type Result = "replayed" | "missed" | "written" | "unchanged"; - -const MAX_RECORDING_BYTES = 10 * 1024 * 1024; -const TENANT_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; -const SHA256_HEX = /^[0-9a-f]{64}$/; -/** The run header that the proxy sends, such as a GitHub Actions run id. */ -const RUN_HEADER = "x-roach-run"; -const MAX_RUN = 128; -const RECORDINGS_PATH = "/v1/recordings/"; - -/** An error for a request that the Worker refuses, with its status. */ -const refusal = (status: number, message: string) => - Object.assign(new Error(message), { name: "Refusal", status }); - -const isRefusal = (error: unknown): error is Error & { status: number } => - error instanceof Error && error.name === "Refusal"; - -const json = (body: unknown, status = 200) => - Response.json(body, { status, headers: { "cache-control": "no-store" } }); - -const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const hexOf = (bytes: ArrayBuffer) => - [...new Uint8Array(bytes)] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); - -const sha256 = async (text: string) => - crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); - -const bytesOf = (hex: string) => - Uint8Array.from(hex.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16)); - -/** The token hashes of `ROACH_TENANTS`. A bad value throws. */ -function parseTenants(value: string | undefined): Array<[string, Uint8Array]> { - if (!value) throw new Error("ROACH_TENANTS is not set"); - const parsed: unknown = JSON.parse(value); - if (!isObject(parsed)) throw new Error("ROACH_TENANTS must be an object"); - return Object.entries(parsed).map(([name, hash]) => { - if (!TENANT_NAME.test(name) || typeof hash !== "string") { - throw new Error(`ROACH_TENANTS has a bad tenant: ${name}`); - } - if (!SHA256_HEX.test(hash)) { - throw new Error(`ROACH_TENANTS needs the SHA-256 hex of ${name}`); - } - return [name, bytesOf(hash)]; - }); -} - -/** The tenant of the bearer token of `request`. */ -async function tenantOf(request: Request, env: Env): Promise { - const tenants = parseTenants(env.ROACH_TENANTS); - const token = /^Bearer (\S+)$/.exec( - request.headers.get("authorization") ?? "", - )?.[1]; - if (token) { - const hash = new Uint8Array(await sha256(token)); - // Compare with every tenant, so the time does not show which matched. - let found: string | undefined; - for (const [name, expected] of tenants) { - if (crypto.subtle.timingSafeEqual(hash, expected)) found = name; - } - if (found) return found; - } - throw refusal(401, "bad token"); -} - -/** The body of `request` as text. A body over the limit is refused. */ -async function readBody(request: Request): Promise { - const tooLarge = () => - refusal(413, `body is over ${MAX_RECORDING_BYTES} bytes`); - const length = Number(request.headers.get("content-length") ?? 0); - if (length > MAX_RECORDING_BYTES) throw tooLarge(); - const body = await request.arrayBuffer(); - if (body.byteLength > MAX_RECORDING_BYTES) throw tooLarge(); - return new TextDecoder().decode(body); -} - -/** Refuse a body that is not a recording (`src/store.ts`). */ -function checkRecording(text: string): void { - let value: unknown; - try { - value = JSON.parse(text); - } catch { - throw refusal(400, "body is not JSON"); - } - const { request, response } = isObject(value) ? value : {}; - if ( - !isObject(request) || - typeof request.method !== "string" || - typeof request.url !== "string" || - !isObject(response) || - !Number.isInteger(response.status) || - !isObject(response.headers) || - typeof response.body !== "string" || - (response.bodyEncoding !== "utf8" && response.bodyEncoding !== "base64") - ) { - throw refusal(400, "body is not a recording"); - } -} - -/** Count one read or write of a recording in Sentry. */ -function countUse( - tenant: string, - key: string, - run: string | null, - result: Result, -) { - Sentry.metrics.count("roach.recording", 1, { - attributes: { - tenant, - rule: key.slice(0, key.indexOf("/")), - key, - result, - run: run ?? "unknown", - }, - }); -} - -async function route(request: Request, env: Env): Promise { - const url = new URL(request.url); - if (!url.pathname.startsWith(RECORDINGS_PATH)) { - return json({ error: "not found" }, 404); - } - const tenant = await tenantOf(request, env); - const key = url.pathname.slice(RECORDINGS_PATH.length); - if (!RECORDING_KEY.test(key)) throw refusal(400, "bad key"); - const run = request.headers.get(RUN_HEADER)?.slice(0, MAX_RUN) ?? null; - const objectKey = `${tenant}/${key}`; - - switch (request.method) { - case "GET": { - const object = await env.RECORDINGS.get(objectKey); - countUse(tenant, key, run, object ? "replayed" : "missed"); - if (!object) return json({ error: "no recording" }, 404); - return new Response(object.body, { - headers: { - "content-type": "application/json", - "cache-control": "no-store", - }, - }); - } - case "PUT": { - const text = await readBody(request); - checkRecording(text); - const digest = hexOf(await sha256(text)); - const previous = await env.RECORDINGS.head(objectKey); - const changed = previous?.customMetadata?.digest !== digest; - if (changed) { - await env.RECORDINGS.put(objectKey, text, { - httpMetadata: { contentType: "application/json" }, - customMetadata: { digest }, - }); - } - countUse(tenant, key, run, changed ? "written" : "unchanged"); - return json({ changed }); - } - default: - return json({ error: "use GET or PUT" }, 405); - } -} - -export default Sentry.withSentry((env) => ({ dsn: env.SENTRY_DSN }), { - async fetch(request, env) { - try { - return await route(request, env); - } catch (error) { - if (isRefusal(error)) { - return json({ error: error.message }, error.status); - } - throw error; - } - }, -} satisfies ExportedHandler); diff --git a/worker/tsconfig.json b/worker/tsconfig.json deleted file mode 100644 index 55da8d5..0000000 --- a/worker/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "lib": ["es2024"], - "types": ["@cloudflare/workers-types"] - }, - "include": ["."] -} diff --git a/wrangler.jsonc b/wrangler.jsonc deleted file mode 100644 index 4ab6448..0000000 --- a/wrangler.jsonc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - // The Roach Worker: a remote store for recordings. See "Worker" in - // README.md. - "name": "roach", - "main": "worker/index.ts", - "compatibility_date": "2026-10-01", - // The Sentry SDK needs AsyncLocalStorage. - "compatibility_flags": ["nodejs_compat"], - "workers_dev": true, - "observability": { "enabled": true }, - // ROACH_TENANTS is a secret: `wrangler secret put ROACH_TENANTS`. - // SENTRY_DSN is an optional secret too. - "secrets": { "required": ["ROACH_TENANTS"] }, - // A lifecycle rule on the bucket deletes recordings after 30 days. - "r2_buckets": [ - { "binding": "RECORDINGS", "bucket_name": "roach-recordings" }, - ], -} From 14c2cb3d00046d34506aa09d358f5b4f52d0d8bf Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:06:36 +0000 Subject: [PATCH 07/11] fix(server): Limit only request bodies, not upstream responses The 64 MiB limit also applied to recorded upstream responses, so a large response failed with a 413 that blamed the client. Only client request bodies now have the limit. --- src/server.ts | 20 +++++++++++++++----- tests/service.test.ts | 11 ++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/server.ts b/src/server.ts index 4ecec9f..0ee48e8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -82,16 +82,24 @@ function authorityOf(origin: URL): string { return `${origin.hostname}:${port}`; } -function readBody(stream: http.IncomingMessage): Promise { +/** + * Read a whole body. A body over `maxBytes` fails with HTTP 413. Only + * request bodies have a limit: the client sends them, and a large upstream + * response is not the fault of the client. + */ +function readBody( + stream: http.IncomingMessage, + maxBytes = Infinity, +): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let size = 0; stream.on("data", (chunk: Buffer) => { size += chunk.length; - if (size > MAX_BODY_BYTES) { + if (size > maxBytes) { // Read the rest without keeping it, so the client gets the 413. chunks.length = 0; - reject(httpError(413, `body is over ${MAX_BODY_BYTES} bytes`)); + reject(httpError(413, `body is over ${maxBytes} bytes`)); return; } chunks.push(chunk); @@ -102,7 +110,9 @@ function readBody(stream: http.IncomingMessage): Promise { } export async function readJson(incoming: http.IncomingMessage): Promise { - return JSON.parse((await readBody(incoming)).toString("utf8") || "{}") as T; + return JSON.parse( + (await readBody(incoming, MAX_BODY_BYTES)).toString("utf8") || "{}", + ) as T; } /** Compare tokens in constant time. */ @@ -179,7 +189,7 @@ async function proxyRequest( method: incoming.method ?? "GET", url, headers: incoming.headers, - body: await readBody(incoming), + body: await readBody(incoming, MAX_BODY_BYTES), }; const rule = recorder.ruleFor(request); diff --git a/tests/service.test.ts b/tests/service.test.ts index 4f20363..f59ec3d 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -75,6 +75,10 @@ beforeEach(async () => { liveRequests = 0; upstream = createServer((incoming, outgoing) => { liveRequests += 1; + if (incoming.headers["x-large"]) { + outgoing.end("x".repeat(65 * 1024 * 1024)); + return; + } // Echo a credential, as an API that shows the key of the caller does. outgoing.writeHead(200, { "content-type": "application/json" }); outgoing.end( @@ -208,11 +212,16 @@ describe("roach service", () => { // A body over the limit gets HTTP 413 and never goes live. const large = await send(run.url, { prompt: "x".repeat(65 * 1024 * 1024) }); expect(large.status).toBe(413); + // The limit is only for requests. A large response is recorded. + const response = await send(run.url, { prompt: "hi" }, { "x-large": "1" }); + expect(response).toMatchObject({ status: 200, source: "live" }); + expect(response.body).toHaveLength(65 * 1024 * 1024); // An ended run refuses its proxy URL. await run.close(); await expect(send(run.url, { prompt: "hi" })).rejects.toThrow(/407/); - expect(liveRequests).toBe(0); + // Only the large response went live. + expect(liveRequests).toBe(1); }); it("redacts request credentials and the secrets of the service", async () => { From 5e48d712f7f6ae9f74f42cfd1901e17c559a2ae9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:10:39 +0000 Subject: [PATCH 08/11] fix(server): Answer bad control bodies with 400 and 413, not 500 The control API sent 500 for every error, also for a body over the limit or a body that is not JSON. It now sends the status of the error, with its message. Any other error is still a 500. --- src/server.ts | 22 +++++++++++++++++----- tests/service.test.ts | 10 ++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/server.ts b/src/server.ts index 0ee48e8..ccaa23c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -109,10 +109,14 @@ function readBody( }); } +/** Read a JSON request body. A body that is not JSON fails with HTTP 400. */ export async function readJson(incoming: http.IncomingMessage): Promise { - return JSON.parse( - (await readBody(incoming, MAX_BODY_BYTES)).toString("utf8") || "{}", - ) as T; + const text = (await readBody(incoming, MAX_BODY_BYTES)).toString("utf8"); + try { + return JSON.parse(text || "{}") as T; + } catch { + throw httpError(400, "body is not JSON"); + } } /** Compare tokens in constant time. */ @@ -415,8 +419,16 @@ export async function listenProxy( } options.control(incoming, outgoing).catch((error: unknown) => { process.stderr.write(`[roach] Control failed: ${String(error)}\n`); - if (!outgoing.headersSent) outgoing.writeHead(500); - outgoing.end(); + // An error with a status, such as 413 from readBody, is the fault of + // the client. Any other error is a bug of the proxy. + const status = (error as { status?: number }).status ?? 500; + if (outgoing.headersSent) { + outgoing.end(); + return; + } + sendJson(outgoing, status, { + error: status === 500 ? "internal error" : (error as Error).message, + }); }); }); server.on("connection", (socket: Socket) => { diff --git a/tests/service.test.ts b/tests/service.test.ts index f59ec3d..cea2c16 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -209,6 +209,16 @@ describe("roach service", () => { ], }); + // The control API answers a bad body with the fault of the client. + const runsUrl = `${service.url}/__roach/runs`; + const notJson = await fetch(runsUrl, { method: "POST", body: "{" }); + expect(notJson.status).toBe(400); + const tooLarge = await fetch(runsUrl, { + method: "POST", + body: "x".repeat(65 * 1024 * 1024), + }); + expect(tooLarge.status).toBe(413); + // A body over the limit gets HTTP 413 and never goes live. const large = await send(run.url, { prompt: "x".repeat(65 * 1024 * 1024) }); expect(large.status).toBe(413); From badfdaa22798f12978319e32da7a10bbe5d62fa9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:21:46 +0000 Subject: [PATCH 09/11] fix(service): Refuse unrecorded requests in public replay runs Anyone can start a replay run without a tenant token. A request that no rule matched still went live, so such a run was an open proxy to every allowed origin. A run without the token now refuses those requests with 403, so it never sends anything live. --- README.md | 3 ++- src/server.ts | 42 +++++++++++++++++++++++++----------------- src/service.ts | 2 ++ tests/service.test.ts | 6 ++++++ 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ca58a16..2bab4ff 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,8 @@ give this hint, because it would have to read every recording. recordings of a tenant are under `/` in the bucket. Tenants never share recordings. - **Reads are public.** Anyone can start a `replay` run of a tenant, so CI - jobs of forks replay without a secret. Such a run never writes. Every + jobs of forks replay without a secret. Such a run never writes, and it + refuses a request that no rule records, so nothing goes live. Every other mode needs the tenant token. So treat each recording as readable by anyone who can make its request. Do not record responses that must stay private. diff --git a/src/server.ts b/src/server.ts index ccaa23c..c9b000c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -173,8 +173,7 @@ function sendUpstream( /** Proxy one request to an allowed origin, through the recorder. */ async function proxyRequest( - recorder: Recorder, - origins: Map, + { recorder, origins, replayOnly }: ProxyTarget, incoming: http.IncomingMessage, outgoing: http.ServerResponse, tunnelOrigin: string | undefined, @@ -197,6 +196,14 @@ async function proxyRequest( }; const rule = recorder.ruleFor(request); + if (!rule && replayOnly) { + // Without this, anyone could send live traffic through a public run. + outgoing.writeHead(403, { "content-type": "text/plain" }); + outgoing.end( + "Roach: this run can only replay, and no rule records this request\n", + ); + return; + } if (!rule) { recorder.countPassthrough(origin.origin, request.headers); const upstream = await sendUpstream(origin, request); @@ -308,6 +315,11 @@ async function control( export interface ProxyTarget { origins: Map; recorder: Recorder; + /** + * Refuse requests that no rule matches, so nothing goes live. The + * service sets this for a run without the tenant token. + */ + replayOnly?: boolean; /** Set when the target stops. Its tunnels then refuse new requests. */ closed?: boolean; } @@ -366,21 +378,17 @@ function serveProxied( outgoing.end("Roach: this run has ended\n"); return; } - proxyRequest( - target.recorder, - target.origins, - incoming, - outgoing, - tunnelOrigin, - ).catch((error: unknown) => { - if (!outgoing.headersSent) { - const status = (error as { status?: number }).status ?? 502; - outgoing.writeHead(status, { "content-type": "text/plain" }); - } - outgoing.end( - `Roach error: ${error instanceof Error ? error.message : String(error)}\n`, - ); - }); + proxyRequest(target, incoming, outgoing, tunnelOrigin).catch( + (error: unknown) => { + if (!outgoing.headersSent) { + const status = (error as { status?: number }).status ?? 502; + outgoing.writeHead(status, { "content-type": "text/plain" }); + } + outgoing.end( + `Roach error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }, + ); } /** diff --git a/src/service.ts b/src/service.ts index f2b64ce..969e8fb 100644 --- a/src/service.ts +++ b/src/service.ts @@ -302,6 +302,8 @@ export async function startRoachService( token, started: Date.now(), origins: parseOrigins(allow.map((origin) => origin.replace(/\/$/, ""))), + // A public run must not be an open proxy to the allowed origins. + replayOnly: !canWrite, // Each run learns only the credentials of its own requests. recorder: createRecorder( { diff --git a/tests/service.test.ts b/tests/service.test.ts index cea2c16..825c8c1 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -130,6 +130,12 @@ describe("roach service", () => { status: 412, source: "missed", }); + // A request that no rule records would go live, so a public run refuses it. + const agent = new ProxyAgent({ uri: reader.url, proxyTunnel: true }); + agents.push(agent); + const unmatched = await request(`${origin}/other`, { dispatcher: agent }); + expect(unmatched.statusCode).toBe(403); + await unmatched.body.dump(); // Without the token, no mode that can write. for (const mode of ["auto", "record", "off"] as const) { await expect(startRun(undefined, mode)).rejects.toThrow(/401/); From 73c5d8e9f06740f5afd7ced887ca0cdb9cd3c75e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:32:29 +0000 Subject: [PATCH 10/11] fix(service): Cap open runs without a token per tenant Anyone can start a replay run without a token, and each run stays in memory for up to 6 hours. So repeated calls could fill the memory of the shared process. A tenant can now have 50 such runs open; another one gets HTTP 429 until one ends. Runs with the tenant token have no cap. --- README.md | 2 ++ src/service.ts | 19 +++++++++++++++++++ tests/service.test.ts | 12 ++++++++++++ 3 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 2bab4ff..6ec6841 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,8 @@ give this hint, because it would have to read every recording. - Runs live in memory in one process. A restart ends the open runs, and their CI jobs fail. A run that is open for 6 hours ends as failed. +- A tenant can have 50 open runs without a token. Another one gets HTTP + 429 until one ends. Runs with the token have no cap. - A request body over 64 MiB gets HTTP 413. - GCS deletes each recording 30 days after it was written, also when runs still replay it. Then `auto` mode records it again, and `replay` mode diff --git a/src/service.ts b/src/service.ts index 969e8fb..eb44f9b 100644 --- a/src/service.ts +++ b/src/service.ts @@ -124,6 +124,7 @@ export interface RoachService { interface Run extends ProxyTarget { id: string; + tenant: string; token: string; started: number; } @@ -131,6 +132,11 @@ interface Run extends ProxyTarget { const MODES = new Set(["auto", "off", "record", "replay"]); /** A run that is open longer than this ends as failed, such as a dead job. */ const RUN_TIMEOUT_MS = 6 * 60 * 60 * 1000; +/** + * The open runs without a token that one tenant can have. Anyone can start + * them, so the cap keeps memory bounded. Runs with the token have no cap. + */ +const MAX_PUBLIC_RUNS = 50; const MAX_RUN_NAME = 128; const sha256 = (value: string) => createHash("sha256").update(value).digest(); @@ -299,6 +305,7 @@ export async function startRoachService( ); const run: Run = { id, + tenant: runConfig.tenant, token, started: Date.now(), origins: parseOrigins(allow.map((origin) => origin.replace(/\/$/, ""))), @@ -378,6 +385,18 @@ export async function startRoachService( }); return; } + if (!canWrite) { + let open = 0; + for (const other of runs.values()) { + if (other.replayOnly && other.tenant === tenant) open += 1; + } + if (open >= MAX_PUBLIC_RUNS) { + sendJson(outgoing, 429, { + error: `${tenant} has ${MAX_PUBLIC_RUNS} open runs without a token`, + }); + return; + } + } sendJson(outgoing, 201, createRun(runConfig as RunConfig, canWrite)); return; } diff --git a/tests/service.test.ts b/tests/service.test.ts index 825c8c1..fc8cb71 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -225,6 +225,18 @@ describe("roach service", () => { }); expect(tooLarge.status).toBe(413); + // Anyone can start a run without a token, so a tenant has a cap on them. + const open = await Promise.all( + Array.from({ length: 50 }, () => startRun(undefined, "replay")), + ); + await expect(startRun(undefined, "replay")).rejects.toThrow(/429/); + // The cap is per tenant, and runs with the token have none. + await startRun(undefined, "replay", { tenant: "beta" }); + await startRun("alpha-token", "auto"); + // An ended run frees its place. + await open[0]!.close(); + await startRun(undefined, "replay"); + // A body over the limit gets HTTP 413 and never goes live. const large = await send(run.url, { prompt: "x".repeat(65 * 1024 * 1024) }); expect(large.status).toBe(413); From a844049bdb8ff764d6a410af81ae330c88f1a37d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:36:04 +0000 Subject: [PATCH 11/11] fix(server): Refuse a control body that is not a JSON object A body such as `null` parsed as JSON, and the caller then read a field of it. That threw a TypeError, so the control API answered 500. A body that is not a JSON object now gets HTTP 400. --- src/server.ts | 10 ++++++++-- tests/service.test.ts | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index c9b000c..be8b5cc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -109,14 +109,20 @@ function readBody( }); } -/** Read a JSON request body. A body that is not JSON fails with HTTP 400. */ +/** Read a JSON object request body. Any other body fails with HTTP 400. */ export async function readJson(incoming: http.IncomingMessage): Promise { const text = (await readBody(incoming, MAX_BODY_BYTES)).toString("utf8"); + let value: unknown; try { - return JSON.parse(text || "{}") as T; + value = JSON.parse(text || "{}"); } catch { throw httpError(400, "body is not JSON"); } + // Callers read fields, so `null`, a list, or a single value is a bad body. + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw httpError(400, "body is not a JSON object"); + } + return value as T; } /** Compare tokens in constant time. */ diff --git a/tests/service.test.ts b/tests/service.test.ts index fc8cb71..ce82641 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -219,6 +219,8 @@ describe("roach service", () => { const runsUrl = `${service.url}/__roach/runs`; const notJson = await fetch(runsUrl, { method: "POST", body: "{" }); expect(notJson.status).toBe(400); + const notObject = await fetch(runsUrl, { method: "POST", body: "null" }); + expect(notObject.status).toBe(400); const tooLarge = await fetch(runsUrl, { method: "POST", body: "x".repeat(65 * 1024 * 1024),